fix: hydration issues caused by duplicate components on servers panel (#3753)

* fix: server stats icons

* fix: fix chart jumping

* refactor: iconComponent -> icon

* fix: panel hydration issues

* fix: apply requested changes
This commit is contained in:
IMB11 2025-06-11 22:30:24 +01:00 committed by GitHub
parent a3839461cf
commit f8fb23e05f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 156 additions and 168 deletions

View File

@ -1,80 +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 mt-1 text-3xl font-extrabold text-contrast">0 B</h2>
</div>
<h3 class="relative z-10 text-base font-normal text-secondary">Storage usage</h3>
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
</div>
</div>
<div
class="relative flex h-full w-full flex-col gap-3 overflow-hidden rounded-2xl bg-bg-raised p-8"
>
<div class="experimental-styles-within flex flex-row items-center">
<div class="flex flex-row items-center gap-4">
<h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2>
</div>
</div>
<div class="relative w-full">
<input type="text" placeholder="Search logs" class="h-12 !w-full !pl-10 !pr-48" />
<SearchIcon class="absolute left-4 top-1/2 -translate-y-1/2" />
</div>
<div
class="console relative h-full min-h-[516px] w-full overflow-hidden rounded-xl bg-bg text-sm"
></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CPUIcon, DBIcon, FolderOpenIcon, SearchIcon } from "@modrinth/assets";
</script>
<style scoped>
html.light-mode .console {
background: var(--color-bg);
}
html.dark-mode .console {
background: black;
}
html.oled-mode .console {
background: black;
}
</style>

View File

@ -7,16 +7,17 @@
type="text"
placeholder="Search logs"
class="h-12 !w-full !pl-10 !pr-48"
:disabled="loading"
@keydown.escape="clearSearch"
/>
<SearchIcon class="absolute left-4 top-1/2 -translate-y-1/2" />
<ButtonStyled v-if="searchInput" @click="clearSearch">
<ButtonStyled v-if="searchInput && !loading" @click="clearSearch">
<button class="absolute right-2 top-1/2 -translate-y-1/2">
<XIcon class="h-5 w-5" />
</button>
</ButtonStyled>
<span
v-if="pyroConsole.filteredOutput.value.length && searchInput"
v-if="pyroConsole.filteredOutput.value.length && searchInput && !loading"
class="pointer-events-none absolute right-12 top-1/2 -translate-y-1/2 select-none whitespace-pre text-sm"
>
{{ pyroConsole.filteredOutput.value.length }}
@ -29,11 +30,13 @@
:class="[
'terminal-font console relative z-[1] flex h-full w-full flex-col items-center justify-between overflow-hidden rounded-t-xl px-1 text-sm transition-transform duration-300',
{ 'scale-fullscreen screen-fixed inset-0 z-50 !rounded-none': isFullScreen },
{ 'pointer-events-none': loading },
]"
:aria-hidden="loading"
tabindex="-1"
>
<div
v-if="cosmetics.advancedRendering"
v-if="cosmetics.advancedRendering && !loading"
class="progressive-gradient pointer-events-none absolute -bottom-6 left-0 z-[2] h-[10rem] w-full overflow-hidden rounded-xl"
:style="`--transparency: ${Math.max(0, lerp(100, 0, bottomThreshold * 8))}%`"
aria-hidden="true"
@ -47,7 +50,7 @@
/>
</div>
<div
v-else
v-else-if="!loading"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-[2] h-[196px] w-full"
:style="
bottomThreshold > 0
@ -79,6 +82,7 @@
</div>
<div data-pyro-terminal-scroll-root class="relative h-full w-full">
<div
v-if="!loading"
ref="scrollbarTrack"
data-pyro-terminal-scrollbar-track
class="absolute -right-1 bottom-16 top-4 z-[4] w-4 overflow-hidden"
@ -118,7 +122,12 @@
class="scrollbar-none absolute left-0 top-0 h-full w-full select-text overflow-x-auto overflow-y-auto py-6 pb-[72px]"
@scroll.passive="() => handleListScroll()"
>
<div data-pyro-terminal-virtual-height-watcher :style="{ height: `${totalHeight}px` }">
<div v-if="loading" class="h-full w-full" />
<div
v-else
data-pyro-terminal-virtual-height-watcher
:style="{ height: `${totalHeight}px` }"
>
<ul
class="m-0 list-none p-0"
data-pyro-terminal-virtual-list
@ -205,6 +214,7 @@
<slot />
</div>
<button
v-if="!loading"
data-pyro-fullscreen
:label="isFullScreen ? 'Exit full screen' : 'Enter full screen'"
class="experimental-styles-within absolute right-4 top-4 z-[3] grid h-12 w-12 place-content-center rounded-full border-[1px] border-solid border-button-border bg-bg-raised text-contrast transition-all duration-200 hover:scale-110 active:scale-95"
@ -217,7 +227,7 @@
<Transition name="fade">
<div
v-if="hasSelection || isSingleLineSelected"
v-if="(hasSelection || isSingleLineSelected) && !loading"
class="absolute right-20 top-4 z-[3] flex flex-row items-center"
:class="{ '!right-4': searchInput || hasSelection || isSingleLineSelected }"
>
@ -247,7 +257,7 @@
<Transition name="scroll-to-bottom">
<button
v-if="bottomThreshold > 0 && !isScrolledToBottom"
v-if="bottomThreshold > 0 && !isScrolledToBottom && !loading"
data-pyro-scrolltobottom
label="Scroll to bottom"
class="scroll-to-bottom-btn experimental-styles-within absolute bottom-[4.5rem] right-4 z-[3] grid h-12 w-12 place-content-center rounded-full border-[1px] border-solid border-button-border bg-bg-raised text-contrast transition-all duration-200 hover:scale-110 active:scale-95"
@ -298,6 +308,7 @@ const cosmetics = $cosmetics;
const props = defineProps<{
fullScreen: boolean;
loading?: boolean;
}>();
const BUFFER_SIZE = 5;
@ -308,7 +319,7 @@ const SCROLL_END_DELAY = 150;
const progressiveBlurIterations = ref(8);
const pyroConsole = usePyroConsole();
const consoleOutput = pyroConsole.output;
const consoleOutput = computed(() => (props.loading ? [] : pyroConsole.output.value));
const scrollContainer = ref<HTMLElement | null>(null);

View File

@ -3,6 +3,8 @@
data-pyro-server-stats
style="font-variant-numeric: tabular-nums"
class="flex select-none flex-col items-center gap-6 md:flex-row"
:class="{ 'pointer-events-none': loading }"
:aria-hidden="loading"
>
<div
v-for="(metric, index) in metrics"
@ -18,7 +20,7 @@
<h3 class="flex items-center gap-2 text-base font-normal text-secondary">
{{ metric.title }}
<IssuesIcon
v-if="metric.warning"
v-if="metric.warning && !loading"
v-tooltip="metric.warning"
class="size-5"
:style="{ color: 'var(--color-orange)' }"
@ -28,37 +30,47 @@
<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" />
<component
:is="metric.icon"
class="absolute right-10 top-10 z-10 size-8"
style="width: 2rem; height: 2rem"
/>
<div class="chart-space absolute bottom-0 left-0 right-0">
<ClientOnly>
<VueApexCharts
v-if="metric.showGraph"
v-if="metric.showGraph && !loading"
type="area"
height="142"
:options="getChartOptions(metric.warning)"
:options="getChartOptions(metric.warning, index)"
:series="[{ name: metric.title, data: metric.data }]"
class="chart absolute bottom-0 left-0 right-0 w-full opacity-0"
class="chart"
:class="chartsReady.has(index) ? 'opacity-100' : 'opacity-0'"
/>
</ClientOnly>
</div>
</div>
<NuxtLink
:to="`/servers/manage/${serverId}/files`"
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"
<component
:is="loading ? 'div' : 'NuxtLink'"
:to="loading ? undefined : `/servers/manage/${serverId}/files`"
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
:class="loading ? '' : 'transition-transform duration-100 hover:scale-105 active:scale-100'"
>
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 mt-1 text-3xl font-extrabold text-contrast">
{{ formatBytes(stats.storage_usage_bytes) }}
{{ loading ? "0 B" : formatBytes(stats.storage_usage_bytes) }}
</h2>
</div>
<h3 class="text-base font-normal text-secondary">Storage usage</h3>
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
</NuxtLink>
</component>
</div>
</template>
<script setup lang="ts">
import { ref, computed, shallowRef } from "vue";
import { FolderOpenIcon, CPUIcon, DBIcon, IssuesIcon } from "@modrinth/assets";
import { FolderOpenIcon, CPUIcon, DatabaseIcon, IssuesIcon } from "@modrinth/assets";
import { useStorage } from "@vueuse/core";
import type { Stats } from "~/types/servers";
@ -66,13 +78,28 @@ const route = useNativeRoute();
const serverId = route.params.id;
const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts"));
const chartsReady = ref(new Set<number>());
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
ramAsNumber: false,
});
const props = defineProps<{ data: Stats }>();
const props = withDefaults(defineProps<{ data?: Stats; loading?: boolean }>(), {
loading: false,
});
const stats = shallowRef(props.data.current);
const stats = shallowRef(
props.data?.current || {
cpu_percent: 0,
ram_usage_bytes: 0,
ram_total_bytes: 1, // Avoid division by zero
storage_usage_bytes: 0,
},
);
const onChartReady = (index: number) => {
chartsReady.value.add(index);
};
const formatBytes = (bytes: number) => {
const units = ["B", "KB", "MB", "GB"];
@ -94,6 +121,29 @@ const updateGraphData = (arr: number[], newValue: number) => {
};
const metrics = computed(() => {
if (props.loading) {
return [
{
title: "CPU usage",
value: "0.00%",
max: "100%",
icon: CPUIcon,
data: cpuData.value,
showGraph: false,
warning: null,
},
{
title: "Memory usage",
value: "0.00%",
max: "100%",
icon: DatabaseIcon,
data: ramData.value,
showGraph: false,
warning: null,
},
];
}
const ramPercent = Math.min(
(stats.value.ram_usage_bytes / stats.value.ram_total_bytes) * 100,
100,
@ -119,7 +169,7 @@ const metrics = computed(() => {
? formatBytes(stats.value.ram_usage_bytes)
: `${ramPercent.toFixed(2)}%`,
max: userPreferences.value.ramAsNumber ? formatBytes(stats.value.ram_total_bytes) : "100%",
icon: DBIcon,
icon: DatabaseIcon,
data: ramData.value,
showGraph: true,
warning: ramPercent >= 90 ? "Memory usage is very high" : null,
@ -127,7 +177,7 @@ const metrics = computed(() => {
];
});
const getChartOptions = (hasWarning: string | null) => ({
const getChartOptions = (hasWarning: string | null, index: number) => ({
chart: {
type: "area",
animations: { enabled: false },
@ -139,6 +189,10 @@ const getChartOptions = (hasWarning: string | null) => ({
top: 0,
bottom: 0,
},
events: {
mounted: () => onChartReady(index),
updated: () => onChartReady(index),
},
},
stroke: { curve: "smooth", width: 3 },
fill: {
@ -172,24 +226,26 @@ const getChartOptions = (hasWarning: string | null) => ({
});
watch(
() => props.data.current,
() => props.data?.current,
(newStats) => {
if (newStats) {
stats.value = newStats;
}
},
);
</script>
<style scoped>
.chart {
animation: fadeIn 0.2s ease-out 0.2s forwards;
.chart-space {
height: 142px;
width: calc(100% + 48px);
margin-left: -24px;
margin-right: -24px;
width: calc(100% + 48px) !important;
}
@keyframes fadeIn {
to {
opacity: 1;
}
.chart {
width: 100% !important;
height: 142px !important;
transition: opacity 0.3s ease-out;
}
</style>

View File

@ -1,11 +1,7 @@
<template>
<div class="relative flex select-none flex-col gap-6" data-pyro-server-manager-root>
<div
v-if="isConnected && !isWsAuthIncorrect"
class="relative flex select-none flex-col gap-6"
data-pyro-server-manager-root
>
<div
v-if="inspectingError"
v-if="inspectingError && isConnected && !isWsAuthIncorrect"
data-pyro-servers-inspecting-error
class="flex justify-between rounded-2xl border-2 border-solid border-red bg-bg-red p-4 font-semibold text-contrast"
>
@ -77,26 +73,34 @@
</ButtonStyled>
</div>
</div>
<div class="flex flex-col-reverse gap-6 md:flex-col">
<UiServersServerStats :data="stats" />
<UiServersServerStats
:data="isConnected && !isWsAuthIncorrect ? stats : undefined"
:loading="!isConnected || isWsAuthIncorrect"
/>
<div
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"
:class="{ 'border-0': !isConnected || isWsAuthIncorrect }"
>
<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
v-if="isConnected && !isWsAuthIncorrect"
:state="serverPowerState"
/>
</div>
</div>
<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">
<UiServersPanelTerminal
:full-screen="fullScreen"
:loading="!isConnected || isWsAuthIncorrect"
>
<div class="relative w-full px-4 pt-4">
<ul
v-if="suggestions.length"
v-if="suggestions.length && isConnected && !isWsAuthIncorrect"
id="command-suggestions"
ref="suggestionsList"
class="mt-1 max-h-60 w-full list-none overflow-auto rounded-md border border-divider bg-bg-raised p-0 shadow-lg"
@ -120,7 +124,7 @@
</ul>
<div class="relative flex items-center">
<span
v-if="bestSuggestion"
v-if="bestSuggestion && isConnected && !isWsAuthIncorrect"
class="pointer-events-none absolute left-[26px] transform select-none text-gray-400"
>
<span class="ml-[23.5px] whitespace-pre">{{
@ -142,7 +146,7 @@
<TerminalSquareIcon class="ml-3 h-5 w-5" />
</div>
<input
v-if="isServerRunning"
v-if="isServerRunning && isConnected && !isWsAuthIncorrect"
v-model="commandInput"
type="text"
placeholder="Send a command"
@ -168,21 +172,17 @@
</UiServersPanelTerminal>
</div>
</div>
</div>
<UiServersOverviewLoading v-else-if="!isConnected && !isWsAuthIncorrect" />
<div v-else-if="isWsAuthIncorrect" class="flex flex-col">
<div
v-if="isWsAuthIncorrect"
class="absolute inset-0 flex flex-col items-center justify-center bg-bg"
>
<h2>Could not connect to the server.</h2>
<p>
An error occurred while attempting to connect to your server. Please try refreshing the page.
(WebSocket Authentication Failed)
An error occurred while attempting to connect to your server. Please try refreshing the
page. (WebSocket Authentication Failed)
</p>
</div>
<div v-else class="flex flex-col">
<h2>Could not connect to the server.</h2>
<p>
An error occurred while attempting to connect to your server. Please try refreshing the page.
(No further information)
</p>
</div>
</template>

View File

@ -1 +1,18 @@
<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-cpu-icon lucide-cpu"><path d="M12 20v2"/><path d="M12 2v2"/><path d="M17 20v2"/><path d="M17 2v2"/><path d="M2 12h2"/><path d="M2 17h2"/><path d="M2 7h2"/><path d="M20 12h2"/><path d="M20 17h2"/><path d="M20 7h2"/><path d="M7 20v2"/><path d="M7 2v2"/><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="8" y="8" width="8" height="8" rx="1"/></svg>
<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-cpu-icon lucide-cpu">
<path d="M12 20v2" />
<path d="M12 2v2" />
<path d="M17 20v2" />
<path d="M17 2v2" />
<path d="M2 12h2" />
<path d="M2 17h2" />
<path d="M2 7h2" />
<path d="M20 12h2" />
<path d="M20 17h2" />
<path d="M20 7h2" />
<path d="M7 20v2" />
<path d="M7 2v2" />
<rect x="4" y="4" width="16" height="16" rx="2" />
<rect x="8" y="8" width="8" height="8" rx="1" />
</svg>

Before

Width:  |  Height:  |  Size: 555 B

After

Width:  |  Height:  |  Size: 648 B

View File

@ -1,14 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="absolute right-8 top-8 size-8"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"
/>
</svg>

Before

Width:  |  Height:  |  Size: 633 B

View File

@ -207,7 +207,6 @@ import _CubeIcon from './icons/cube.svg?component'
import _CloudIcon from './icons/cloud.svg?component'
import _CogIcon from './icons/cog.svg?component'
import _CPUIcon from './icons/cpu.svg?component'
import _DBIcon from './icons/db.svg?component'
import _LoaderIcon from './icons/loader.svg?component'
import _ImportIcon from './icons/import.svg?component'
import _TimerIcon from './icons/timer.svg?component'
@ -438,7 +437,6 @@ export const CubeIcon = _CubeIcon
export const CloudIcon = _CloudIcon
export const CogIcon = _CogIcon
export const CPUIcon = _CPUIcon
export const DBIcon = _DBIcon
export const LoaderIcon = _LoaderIcon
export const ImportIcon = _ImportIcon
export const CardIcon = _CardIcon