mirror of
https://github.com/modrinth/code.git
synced 2026-08-27 01:54:47 +00:00
feat: implement kryos upload sessions (#6145)
* feat: implement upload sessions * fix: files not scoped * feat: hide staging files folder and proper cancel feedback * fix: lint
This commit is contained in:
@@ -1,74 +0,0 @@
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
type BackupQueueBackup = Archon.BackupsQueue.v1.BackupQueueBackup
|
||||
|
||||
export function useBackupsSelection(
|
||||
visibleBackups: Ref<BackupQueueBackup[]>,
|
||||
displayOrderedBackups: ComputedRef<BackupQueueBackup[]>,
|
||||
) {
|
||||
const selectedIds = ref<Set<string>>(new Set())
|
||||
|
||||
watch(visibleBackups, () => {
|
||||
const ids = new Set(visibleBackups.value.map((b) => b.id))
|
||||
const next = new Set<string>()
|
||||
for (const id of selectedIds.value) {
|
||||
if (ids.has(id)) next.add(id)
|
||||
}
|
||||
if (next.size !== selectedIds.value.size) {
|
||||
selectedIds.value = next
|
||||
}
|
||||
})
|
||||
|
||||
function toggleSelection(id: string) {
|
||||
const next = new Set(selectedIds.value)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
selectedIds.value = next
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedIds.value = new Set(visibleBackups.value.map((b) => b.id))
|
||||
}
|
||||
|
||||
function deselectAll() {
|
||||
selectedIds.value = new Set()
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allSelected.value) deselectAll()
|
||||
else selectAll()
|
||||
}
|
||||
|
||||
const allSelected = computed(
|
||||
() =>
|
||||
visibleBackups.value.length > 0 &&
|
||||
visibleBackups.value.every((b) => selectedIds.value.has(b.id)),
|
||||
)
|
||||
|
||||
const someSelected = computed(() => {
|
||||
const vis = visibleBackups.value
|
||||
if (vis.length === 0) return false
|
||||
let n = 0
|
||||
for (const b of vis) {
|
||||
if (selectedIds.value.has(b.id)) n++
|
||||
}
|
||||
return n > 0 && n < vis.length
|
||||
})
|
||||
|
||||
const selectedBackups = computed(() =>
|
||||
displayOrderedBackups.value.filter((b) => selectedIds.value.has(b.id)),
|
||||
)
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
toggleSelection,
|
||||
selectAll,
|
||||
deselectAll,
|
||||
toggleSelectAll,
|
||||
allSelected,
|
||||
someSelected,
|
||||
selectedBackups,
|
||||
}
|
||||
}
|
||||
@@ -258,6 +258,7 @@ import BackupDeleteModal from '#ui/components/servers/backups/BackupDeleteModal.
|
||||
import BackupItem from '#ui/components/servers/backups/BackupItem.vue'
|
||||
import BackupRenameModal from '#ui/components/servers/backups/BackupRenameModal.vue'
|
||||
import BackupRestoreModal from '#ui/components/servers/backups/BackupRestoreModal.vue'
|
||||
import { useBackupsSelection } from '#ui/composables/hosting/backups-selection'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
|
||||
import { useBulkOperation } from '#ui/layouts/shared/content-tab/composables/bulk-operations'
|
||||
@@ -268,8 +269,6 @@ import {
|
||||
} from '#ui/providers'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import { useBackupsSelection } from './backups-selection'
|
||||
|
||||
const messages = defineMessages({
|
||||
selectAll: {
|
||||
id: 'servers.backups.toolbar.select-all',
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
data-pyro-server-stats
|
||||
style="font-variant-numeric: tabular-nums"
|
||||
class="flex select-none flex-col items-center gap-3 md:flex-row"
|
||||
:class="{ 'pointer-events-none': loading }"
|
||||
:aria-hidden="loading"
|
||||
>
|
||||
<component
|
||||
:is="metric.link ? RouterLink : 'div'"
|
||||
v-for="(metric, index) in metrics"
|
||||
:key="index"
|
||||
:to="metric.link && !loading ? metric.link : undefined"
|
||||
class="relative isolate min-h-[145px] w-full overflow-hidden rounded-[20px] bg-surface-3 p-5"
|
||||
:class="
|
||||
metric.link && !loading
|
||||
? 'cursor-pointer transition-transform duration-100 hover:brightness-125 active:scale-95'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<div class="relative z-10 flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="stat-drop-shadow flex items-center gap-2 font-medium text-lg text-primary">
|
||||
{{ metric.title }}
|
||||
</span>
|
||||
<span class="relative">
|
||||
<component :is="metric.icon" class="stat-drop-shadow relative z-10 size-8" />
|
||||
<!-- <div
|
||||
class="absolute -right-4 -top-4 -z-10 size-14 rounded-full bg-surface-3 opacity-50 blur-lg"
|
||||
/> -->
|
||||
</span>
|
||||
</div>
|
||||
<span class="stat-drop-shadow text-4xl font-bold text-contrast">
|
||||
{{ metric.value
|
||||
}}<span
|
||||
v-if="metric.secondary"
|
||||
class="ml-1 text-sm font-normal stat-drop-shadow text-secondary"
|
||||
>{{ metric.secondary }}</span
|
||||
>
|
||||
</span>
|
||||
<!-- <div
|
||||
class="absolute -left-8 -top-4 -z-10 h-28 w-56 rounded-full bg-surface-3 opacity-50 blur-lg"
|
||||
/> -->
|
||||
</div>
|
||||
|
||||
<div v-if="metric.showGraph" class="chart-space absolute bottom-0 left-0 right-0">
|
||||
<VueApexCharts
|
||||
v-if="isClient && !loading && metric.chartOptions"
|
||||
type="area"
|
||||
height="142"
|
||||
:options="metric.chartOptions"
|
||||
:series="metric.series!"
|
||||
class="chart"
|
||||
:class="chartsReady.has(index) ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
</div>
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CpuIcon, DatabaseIcon, FolderOpenIcon } from '@modrinth/assets'
|
||||
import type { Stats } from '@modrinth/utils'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed, defineAsyncComponent, onMounted, ref, shallowRef, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { useFormatBytes } from '#ui/composables'
|
||||
import { injectModrinthServerContext, injectPageContext } from '#ui/providers'
|
||||
|
||||
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
|
||||
|
||||
// apexcharts touches `window` at module load time, so we must not let SSR
|
||||
// resolve the async component. Render only after mount on the client.
|
||||
const isClient = ref(false)
|
||||
onMounted(() => {
|
||||
isClient.value = true
|
||||
})
|
||||
|
||||
const { serverId } = injectModrinthServerContext()
|
||||
const { featureFlags } = injectPageContext()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
data?: Stats
|
||||
loading?: boolean
|
||||
showMemoryAsBytes?: boolean
|
||||
}>(),
|
||||
{
|
||||
data: undefined,
|
||||
loading: false,
|
||||
showMemoryAsBytes: false,
|
||||
},
|
||||
)
|
||||
|
||||
const formatBytes = useFormatBytes()
|
||||
|
||||
const chartsReady = ref(new Set<number>())
|
||||
const userPreferences = useStorage(`pyro-server-${serverId || 'unknown'}-preferences`, {
|
||||
ramAsNumber: false,
|
||||
})
|
||||
const isRamAsBytesForcedByFeatureFlag = computed(
|
||||
() => featureFlags?.serverRamAsBytesAlwaysOn?.value ?? false,
|
||||
)
|
||||
|
||||
const showRamAsBytes = computed(
|
||||
() =>
|
||||
props.showMemoryAsBytes ||
|
||||
isRamAsBytesForcedByFeatureFlag.value ||
|
||||
userPreferences.value.ramAsNumber,
|
||||
)
|
||||
|
||||
const stats = shallowRef(
|
||||
props.data?.current || {
|
||||
cpu_percent: 0,
|
||||
ram_usage_bytes: 0,
|
||||
ram_total_bytes: 1,
|
||||
storage_usage_bytes: 0,
|
||||
},
|
||||
)
|
||||
|
||||
const GRAPH_SIZE = 10
|
||||
|
||||
const padGraph = (data: number[]) => {
|
||||
const capped = data.map((v) => Math.min(v, 100))
|
||||
if (capped.length >= GRAPH_SIZE) return capped.slice(-GRAPH_SIZE)
|
||||
return [...Array(GRAPH_SIZE - capped.length).fill(0), ...capped]
|
||||
}
|
||||
|
||||
const cpuData = computed(() => padGraph(props.data?.graph.cpu ?? []))
|
||||
const ramData = computed(() => padGraph(props.data?.graph.ram ?? []))
|
||||
|
||||
const cpuPercent = computed(() => stats.value.cpu_percent ?? 0)
|
||||
const ramPercent = computed(
|
||||
() => ((stats.value.ram_usage_bytes ?? 0) / (stats.value.ram_total_bytes || 1)) * 100,
|
||||
)
|
||||
|
||||
const cpuWarning = computed(() => cpuPercent.value >= 90)
|
||||
const ramWarning = computed(() => ramPercent.value >= 90)
|
||||
|
||||
const cpuDataMax = 104
|
||||
const ramDataMax = 104
|
||||
|
||||
const onChartReady = (index: number) => {
|
||||
chartsReady.value.add(index)
|
||||
}
|
||||
|
||||
const buildChartOptions = (warning: boolean, index: number, dataMax: number) => ({
|
||||
chart: {
|
||||
type: 'area' as const,
|
||||
animations: { enabled: false },
|
||||
sparkline: { enabled: true },
|
||||
toolbar: { show: false },
|
||||
padding: { left: -10, right: -10, top: 0, bottom: 0 },
|
||||
events: {
|
||||
mounted: () => onChartReady(index),
|
||||
updated: () => onChartReady(index),
|
||||
},
|
||||
},
|
||||
stroke: { curve: 'smooth' as const, width: 3 },
|
||||
fill: {
|
||||
type: 'gradient' as const,
|
||||
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' as const,
|
||||
tickAmount: GRAPH_SIZE,
|
||||
},
|
||||
yaxis: { show: false, min: 0, max: dataMax, forceNiceScale: false },
|
||||
colors: [warning ? 'var(--color-orange)' : 'var(--color-brand)'],
|
||||
dataLabels: { enabled: false },
|
||||
})
|
||||
|
||||
const cpuChartOptions = computed(() => buildChartOptions(cpuWarning.value, 0, cpuDataMax))
|
||||
const ramChartOptions = computed(() => buildChartOptions(ramWarning.value, 1, ramDataMax))
|
||||
|
||||
const cpuSeries = computed(() => [{ name: 'CPU', data: cpuData.value }])
|
||||
const ramSeries = computed(() => [{ name: 'Memory', data: ramData.value }])
|
||||
|
||||
const metrics = computed(() => {
|
||||
const storageMetric = {
|
||||
title: 'Storage',
|
||||
value: formatBytes(props.loading ? 0 : (stats.value.storage_usage_bytes ?? 0), 1),
|
||||
secondary: null as string | null,
|
||||
icon: FolderOpenIcon,
|
||||
showGraph: false,
|
||||
chartOptions: null as ReturnType<typeof buildChartOptions> | null,
|
||||
series: null as { name: string; data: number[] }[] | null,
|
||||
link: `/hosting/manage/${encodeURIComponent(serverId)}/files`,
|
||||
}
|
||||
|
||||
if (props.loading) {
|
||||
return [
|
||||
{
|
||||
title: 'CPU',
|
||||
value: '0.00%',
|
||||
secondary: null as string | null,
|
||||
icon: CpuIcon,
|
||||
showGraph: true,
|
||||
chartOptions: cpuChartOptions.value,
|
||||
series: cpuSeries.value,
|
||||
link: null,
|
||||
},
|
||||
{
|
||||
title: 'Memory',
|
||||
value: '0.00%',
|
||||
secondary: null as string | null,
|
||||
icon: DatabaseIcon,
|
||||
showGraph: true,
|
||||
chartOptions: ramChartOptions.value,
|
||||
series: ramSeries.value,
|
||||
link: null,
|
||||
},
|
||||
storageMetric,
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
title: 'CPU',
|
||||
value: `${cpuPercent.value.toFixed(2)}%`,
|
||||
secondary: null as string | null,
|
||||
icon: CpuIcon,
|
||||
showGraph: true,
|
||||
chartOptions: cpuChartOptions.value,
|
||||
series: cpuSeries.value,
|
||||
link: null,
|
||||
},
|
||||
{
|
||||
title: 'Memory',
|
||||
value: showRamAsBytes.value
|
||||
? formatBytes(stats.value.ram_usage_bytes ?? 0, 1)
|
||||
: `${ramPercent.value.toFixed(2)}%`,
|
||||
secondary: showRamAsBytes.value
|
||||
? `/ ${formatBytes(stats.value.ram_total_bytes ?? 0, 1)}`
|
||||
: (null as string | null),
|
||||
icon: DatabaseIcon,
|
||||
showGraph: true,
|
||||
chartOptions: ramChartOptions.value,
|
||||
series: ramSeries.value,
|
||||
link: null,
|
||||
},
|
||||
storageMetric,
|
||||
]
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.data?.current,
|
||||
(newStats) => {
|
||||
if (newStats) {
|
||||
stats.value = newStats
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-drop-shadow {
|
||||
filter: drop-shadow(0 4px 6px var(--surface-3));
|
||||
}
|
||||
|
||||
.chart-space {
|
||||
height: 142px;
|
||||
width: calc(100% + 40px);
|
||||
margin-left: -20px;
|
||||
margin-right: -20px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100% !important;
|
||||
height: 142px !important;
|
||||
transition: opacity 0.3s ease-out;
|
||||
box-shadow:
|
||||
0 1px 2px 0 rgba(0, 0, 0, 0.3),
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.chart :deep(svg) {
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
@@ -7,6 +7,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
|
||||
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
@@ -113,6 +114,13 @@ const messages = defineMessages({
|
||||
const client = injectModrinthClient()
|
||||
const { server, worldId, busyReasons, isSyncingContent, uploadState, cancelUpload } =
|
||||
injectModrinthServerContext()
|
||||
const contentUploadSession = useUploadSessionUpload({
|
||||
client,
|
||||
scope: 'content',
|
||||
worldId,
|
||||
uploadState,
|
||||
cancelUpload,
|
||||
})
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { openServerSettings, browseServerContent } = injectServerSettingsModal()
|
||||
const route = useRoute()
|
||||
@@ -812,47 +820,17 @@ function handleUploadFiles() {
|
||||
const wid = worldId.value
|
||||
if (!wid) return
|
||||
|
||||
uploadState.value = {
|
||||
isUploading: true,
|
||||
currentFileName: null,
|
||||
currentFileProgress: 0,
|
||||
uploadedBytes: 0,
|
||||
totalBytes: files.reduce((sum, f) => sum + f.size, 0),
|
||||
completedFiles: 0,
|
||||
totalFiles: files.length,
|
||||
}
|
||||
|
||||
const handle = client.kyros.content_v1.uploadAddonFile(wid, files, {
|
||||
onProgress: (p) => {
|
||||
uploadState.value.currentFileProgress = p.progress
|
||||
uploadState.value.uploadedBytes = p.loaded
|
||||
uploadState.value.totalBytes = p.total
|
||||
},
|
||||
})
|
||||
cancelUpload.value = () => handle.cancel()
|
||||
|
||||
try {
|
||||
await handle.promise
|
||||
uploadState.value.completedFiles = files.length
|
||||
await contentQuery.refetch()
|
||||
const result = await contentUploadSession.uploadFiles(
|
||||
files.map((file) => ({ file, filename: file.name })),
|
||||
)
|
||||
if (result === 'completed') await contentQuery.refetch()
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === 'Upload cancelled') return
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.failedToUpload),
|
||||
text: err instanceof Error ? err.message : undefined,
|
||||
})
|
||||
} finally {
|
||||
cancelUpload.value = null
|
||||
uploadState.value = {
|
||||
isUploading: false,
|
||||
currentFileName: null,
|
||||
currentFileProgress: 0,
|
||||
uploadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
completedFiles: 0,
|
||||
totalFiles: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
|
||||
import { useReadyState } from '#ui/composables'
|
||||
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
@@ -25,7 +26,21 @@ const props = defineProps<{
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const serverContext = injectModrinthServerContext()
|
||||
const { serverId, fsOps, busyReasons, uploadState, cancelUpload: cancelUploadRef } = serverContext
|
||||
const {
|
||||
serverId,
|
||||
worldId,
|
||||
fsOps,
|
||||
busyReasons,
|
||||
uploadState,
|
||||
cancelUpload: cancelUploadRef,
|
||||
} = serverContext
|
||||
const fileUploadSession = useUploadSessionUpload({
|
||||
client,
|
||||
scope: 'files',
|
||||
worldId,
|
||||
uploadState,
|
||||
cancelUpload: cancelUploadRef,
|
||||
})
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -113,7 +128,13 @@ const {
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const items = computed<FileItem[]>(() => directoryData.value?.items ?? [])
|
||||
function isVisibleFileItem(item: Kyros.Files.v0.DirectoryItem) {
|
||||
return !item.path.split('/').includes('.modrinth-staged')
|
||||
}
|
||||
|
||||
const items = computed<FileItem[]>(() =>
|
||||
(directoryData.value?.items ?? []).filter(isVisibleFileItem),
|
||||
)
|
||||
|
||||
const filesReadyPending = useReadyState({ isLoading, data: directoryData })
|
||||
|
||||
@@ -365,71 +386,33 @@ async function restartServer() {
|
||||
await client.archon.servers_v0.power(serverId, 'Restart')
|
||||
}
|
||||
|
||||
let activeUploadCancel: (() => void) | null = null
|
||||
function getSessionUploadFilename(fileName: string) {
|
||||
const basePath = currentPath.value.split('/').filter(Boolean).join('/')
|
||||
return basePath ? `${basePath}/${fileName}` : fileName
|
||||
}
|
||||
|
||||
async function uploadFiles(files: File[]) {
|
||||
if (files.length === 0) return
|
||||
|
||||
const totalBytes = files.reduce((sum, f) => sum + f.size, 0)
|
||||
uploadState.value = {
|
||||
isUploading: true,
|
||||
currentFileName: files[0].name,
|
||||
currentFileProgress: 0,
|
||||
uploadedBytes: 0,
|
||||
totalBytes,
|
||||
completedFiles: 0,
|
||||
totalFiles: files.length,
|
||||
}
|
||||
cancelUploadRef.value = () => activeUploadCancel?.()
|
||||
|
||||
let completedBytes = 0
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
const filePath = `${currentPath.value}/${file.name}`.replace('//', '/')
|
||||
|
||||
uploadState.value.currentFileName = file.name
|
||||
uploadState.value.currentFileProgress = 0
|
||||
|
||||
try {
|
||||
const uploader = client.kyros.files_v0.uploadFile(filePath, file, {
|
||||
onProgress: ({ progress }) => {
|
||||
uploadState.value.currentFileProgress = progress
|
||||
uploadState.value.uploadedBytes = completedBytes + Math.round(file.size * progress)
|
||||
},
|
||||
})
|
||||
activeUploadCancel = () => uploader.cancel()
|
||||
|
||||
await uploader.promise
|
||||
completedBytes += file.size
|
||||
uploadState.value.completedFiles = i + 1
|
||||
uploadState.value.uploadedBytes = completedBytes
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === 'Upload cancelled') break
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.uploadFailedLabel),
|
||||
text: `Failed to upload ${file.name}`,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
activeUploadCancel = null
|
||||
cancelUploadRef.value = null
|
||||
refreshList()
|
||||
uploadState.value = {
|
||||
isUploading: false,
|
||||
currentFileName: null,
|
||||
currentFileProgress: 0,
|
||||
uploadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
completedFiles: 0,
|
||||
totalFiles: 0,
|
||||
try {
|
||||
const result = await fileUploadSession.uploadFiles(
|
||||
files.map((file) => ({
|
||||
file,
|
||||
filename: getSessionUploadFilename(file.name),
|
||||
})),
|
||||
)
|
||||
if (result === 'completed') refreshList()
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.uploadFailedLabel),
|
||||
text: err instanceof Error ? err.message : undefined,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function cancelUpload() {
|
||||
activeUploadCancel?.()
|
||||
fileUploadSession.cancelUpload()
|
||||
}
|
||||
|
||||
// Provide the file manager context
|
||||
|
||||
@@ -35,17 +35,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// No ReadyTransition wrapper: console and ServerManageStats own their loading UX; there is no single TanStack "ready" gate for this tab.
|
||||
import type { Mclogs } from '@modrinth/api-client'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import ServerManageStats from '#ui/components/servers/ServerManageStats.vue'
|
||||
import { useModrinthServersConsole } from '#ui/composables'
|
||||
import { ConsolePageLayout, provideConsoleManager } from '#ui/layouts/shared/console'
|
||||
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
|
||||
|
||||
import ServerManageStats from './components/ServerManageStats.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
showAdvancedDebugInfo?: boolean
|
||||
|
||||
Reference in New Issue
Block a user