forked from didirus/AstralRinth
feat: modrinth hosting - files tab refactor (#4912)
* feat: api-client module for content v0 * feat: delete unused components + modules + setting * feat: xhr uploading * feat: fs module -> api-client * feat: migrate files.vue to use tanstack * fix: mem leak + other issues * fix: build * feat: switch to monaco * fix: go back to using ace, but improve preloading + theme * fix: styling + dead attrs * feat: match figma * fix: padding * feat: files-new for ui page structure * feat: finalize files.vue * fix: lint * fix: qa * fix: dep * fix: lint * fix: lockfile merge * feat: icons on navtab * fix: surface alternating on table * fix: hover surface color --------- Signed-off-by: Calum H. <contact@cal.engineer> Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
@@ -374,11 +374,16 @@
|
||||
<script setup lang="ts">
|
||||
import { Intercom, shutdown } from '@intercom/messenger-js-sdk'
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { clearNodeAuthState, setNodeAuthState } from '@modrinth/api-client'
|
||||
import {
|
||||
BoxesIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
DatabaseBackupIcon,
|
||||
FileIcon,
|
||||
FolderOpenIcon,
|
||||
IssuesIcon,
|
||||
LayoutTemplateIcon,
|
||||
LeftArrowIcon,
|
||||
LockIcon,
|
||||
RightArrowIcon,
|
||||
@@ -451,7 +456,7 @@ const loadModulesPromise = Promise.resolve().then(() => {
|
||||
if (server.general?.status === 'suspended') {
|
||||
return
|
||||
}
|
||||
return server.refresh(['content', 'backups', 'network', 'startup', 'fs'])
|
||||
return server.refresh(['content', 'backups', 'network', 'startup'])
|
||||
})
|
||||
|
||||
provide('modulesLoaded', loadModulesPromise)
|
||||
@@ -497,6 +502,22 @@ const markBackupCancelled = (backupId: string) => {
|
||||
cancelledBackups.add(backupId)
|
||||
}
|
||||
|
||||
const fsAuth = ref<{ url: string; token: string } | null>(null)
|
||||
const fsOps = ref<Archon.Websocket.v0.FilesystemOperation[]>([])
|
||||
const fsQueuedOps = ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([])
|
||||
|
||||
const refreshFsAuth = async () => {
|
||||
try {
|
||||
const auth = await client.archon.servers_v0.getFilesystemAuth(serverId)
|
||||
fsAuth.value = auth
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh filesystem auth:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
setNodeAuthState(() => fsAuth.value, refreshFsAuth)
|
||||
|
||||
provideModrinthServerContext({
|
||||
serverId,
|
||||
server: n_server as Ref<Archon.Servers.v0.Server>,
|
||||
@@ -505,6 +526,10 @@ provideModrinthServerContext({
|
||||
isServerRunning,
|
||||
backupsState,
|
||||
markBackupCancelled,
|
||||
fsAuth,
|
||||
fsOps,
|
||||
fsQueuedOps,
|
||||
refreshFsAuth,
|
||||
})
|
||||
|
||||
const uptimeSeconds = ref(0)
|
||||
@@ -551,17 +576,29 @@ const showGameLabel = computed(() => !!serverData.value?.game)
|
||||
const showLoaderLabel = computed(() => !!serverData.value?.loader)
|
||||
|
||||
const navLinks = [
|
||||
{ label: 'Overview', href: `/hosting/manage/${serverId}`, subpages: [] },
|
||||
{
|
||||
label: 'Overview',
|
||||
href: `/hosting/manage/${serverId}`,
|
||||
icon: LayoutTemplateIcon,
|
||||
subpages: [],
|
||||
},
|
||||
{
|
||||
label: 'Content',
|
||||
href: `/hosting/manage/${serverId}/content`,
|
||||
icon: BoxesIcon,
|
||||
subpages: ['mods', 'datapacks'],
|
||||
},
|
||||
{ label: 'Files', href: `/hosting/manage/${serverId}/files`, subpages: [] },
|
||||
{ label: 'Backups', href: `/hosting/manage/${serverId}/backups`, subpages: [] },
|
||||
{ label: 'Files', href: `/hosting/manage/${serverId}/files`, icon: FolderOpenIcon, subpages: [] },
|
||||
{
|
||||
label: 'Backups',
|
||||
href: `/hosting/manage/${serverId}/backups`,
|
||||
icon: DatabaseBackupIcon,
|
||||
subpages: [],
|
||||
},
|
||||
{
|
||||
label: 'Options',
|
||||
href: `/hosting/manage/${serverId}/options`,
|
||||
icon: SettingsIcon,
|
||||
subpages: ['startup', 'network', 'properties', 'info'],
|
||||
},
|
||||
]
|
||||
@@ -767,24 +804,29 @@ const handleBackupProgress = (data: Archon.Websocket.v0.WSBackupProgressEvent) =
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) => {
|
||||
if (!server.fs) {
|
||||
console.error('FilesystemOps received, but server.fs is not available', data)
|
||||
return
|
||||
}
|
||||
const opsQueuedForModification = ref<string[]>([])
|
||||
|
||||
const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) => {
|
||||
const allOps = data.all
|
||||
|
||||
if (JSON.stringify(server.fs.ops) !== JSON.stringify(allOps)) {
|
||||
server.fs.ops = allOps as unknown as ModrinthServer['fs']['ops']
|
||||
if (JSON.stringify(fsOps.value) !== JSON.stringify(allOps)) {
|
||||
fsOps.value = allOps
|
||||
}
|
||||
|
||||
server.fs.queuedOps = server.fs.queuedOps.filter(
|
||||
fsQueuedOps.value = fsQueuedOps.value.filter(
|
||||
(queuedOp) => !allOps.some((x) => x.src === queuedOp.src),
|
||||
)
|
||||
|
||||
const dismissOp = async (opId: string) => {
|
||||
try {
|
||||
await client.kyros.files_v0.modifyOperation(opId, 'dismiss')
|
||||
} catch (error) {
|
||||
console.error('Failed to dismiss operation:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelled = allOps.filter((x) => x.state === 'cancelled')
|
||||
Promise.all(cancelled.map((x) => server.fs?.modifyOp(x.id, 'dismiss')))
|
||||
Promise.all(cancelled.map((x) => dismissOp(x.id)))
|
||||
|
||||
const completed = allOps.filter((x) => x.state === 'done')
|
||||
if (completed.length > 0) {
|
||||
@@ -792,9 +834,9 @@ const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) =>
|
||||
async () =>
|
||||
await Promise.all(
|
||||
completed.map((x) => {
|
||||
if (!server.fs?.opsQueuedForModification.includes(x.id)) {
|
||||
server.fs?.opsQueuedForModification.push(x.id)
|
||||
return server.fs?.modifyOp(x.id, 'dismiss')
|
||||
if (!opsQueuedForModification.value.includes(x.id)) {
|
||||
opsQueuedForModification.value.push(x.id)
|
||||
return dismissOp(x.id)
|
||||
}
|
||||
return Promise.resolve()
|
||||
}),
|
||||
@@ -885,22 +927,27 @@ const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallation
|
||||
errorTitle.value = 'Installation error'
|
||||
errorMessage.value = data.reason ?? 'Unknown error'
|
||||
error.value = new Error(data.reason ?? 'Unknown error')
|
||||
let files = await server.fs?.listDirContents('/', 1, 100)
|
||||
if (files) {
|
||||
if (files.total > 1) {
|
||||
for (let i = 1; i < files.total; i++) {
|
||||
const nextFiles = await server.fs?.listDirContents('/', i, 100)
|
||||
|
||||
// Fetch installation log if available
|
||||
try {
|
||||
let files = await client.kyros.files_v0.listDirectory('/', 1, 100)
|
||||
if (files && files.total > 1) {
|
||||
for (let i = 2; i <= files.total; i++) {
|
||||
const nextFiles = await client.kyros.files_v0.listDirectory('/', i, 100)
|
||||
if (nextFiles?.items?.length === 0) break
|
||||
if (nextFiles) files = nextFiles
|
||||
}
|
||||
}
|
||||
}
|
||||
const fileName = files?.items?.find((file: { name: string }) =>
|
||||
file.name.startsWith('modrinth-installation'),
|
||||
)?.name
|
||||
errorLogFile.value = fileName ?? ''
|
||||
if (fileName) {
|
||||
errorLog.value = await server.fs?.downloadFile(fileName)
|
||||
const fileName = files?.items?.find((file) =>
|
||||
file.name.startsWith('modrinth-installation'),
|
||||
)?.name
|
||||
errorLogFile.value = fileName ?? ''
|
||||
if (fileName) {
|
||||
const content = await client.kyros.files_v0.downloadFile(fileName)
|
||||
errorLog.value = await content.text()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch installation log:', err)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -1133,6 +1180,8 @@ const cleanup = () => {
|
||||
completedBackupTasks.clear()
|
||||
cancelledBackups.clear()
|
||||
|
||||
clearNodeAuthState()
|
||||
|
||||
DOMPurify.removeHook('afterSanitizeAttributes')
|
||||
}
|
||||
|
||||
|
||||
@@ -100,13 +100,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<FilesUploadDropdown
|
||||
v-if="props.server.fs"
|
||||
ref="uploadDropdownRef"
|
||||
class="rounded-xl bg-bg-raised"
|
||||
:margin-bottom="16"
|
||||
:file-type="type"
|
||||
:current-path="`/${type.toLocaleLowerCase()}s`"
|
||||
:fs="props.server.fs"
|
||||
:accepted-types="acceptFileFromProjectType(type.toLocaleLowerCase()).split(',')"
|
||||
@upload-complete="() => props.server.refresh(['content'])"
|
||||
/>
|
||||
@@ -355,7 +353,7 @@ import {
|
||||
TrashIcon,
|
||||
WrenchIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, ButtonStyled, injectNotificationManager } from '@modrinth/ui'
|
||||
import { Avatar, ButtonStyled, injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
|
||||
import type { Mod } from '@modrinth/utils'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
@@ -369,6 +367,8 @@ import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
import { acceptFileFromProjectType } from '~/helpers/fileUtils.js'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer
|
||||
}>()
|
||||
@@ -621,7 +621,7 @@ async function toggleMod(mod: ContentItem) {
|
||||
mod.disabled = newFilename.endsWith('.disabled')
|
||||
mod.filename = newFilename
|
||||
|
||||
await props.server.fs?.moveFileOrFolder(sourcePath, destinationPath)
|
||||
await client.kyros.files_v0.moveFileOrFolder(sourcePath, destinationPath)
|
||||
|
||||
await props.server.refresh(['general', 'content'])
|
||||
} catch (error) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -205,6 +205,9 @@ type ServerProps = {
|
||||
|
||||
const props = defineProps<ServerProps>()
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const serverId = props.server.serverId
|
||||
|
||||
interface ErrorData {
|
||||
id: string
|
||||
name: string
|
||||
@@ -242,7 +245,8 @@ const inspectingError = ref<ErrorData | null>(null)
|
||||
|
||||
const inspectError = async () => {
|
||||
try {
|
||||
const log = await props.server.fs?.downloadFile('logs/latest.log')
|
||||
const blob = await client.kyros.files_v0.downloadFile('/logs/latest.log')
|
||||
const log = await blob.text()
|
||||
if (!log) return
|
||||
|
||||
// @ts-ignore
|
||||
@@ -287,9 +291,6 @@ if (props.serverPowerState === 'crashed' && !props.powerStateDetails?.oom_killed
|
||||
inspectError()
|
||||
}
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const serverId = props.server.serverId
|
||||
|
||||
const DYNAMIC_ARG = Symbol('DYNAMIC_ARG')
|
||||
|
||||
const commandTree: any = {
|
||||
|
||||
@@ -116,13 +116,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EditIcon, TransferIcon } from '@modrinth/assets'
|
||||
import { injectNotificationManager, ServerIcon } from '@modrinth/ui'
|
||||
import { injectModrinthClient, injectNotificationManager, ServerIcon } from '@modrinth/ui'
|
||||
import ButtonStyled from '@modrinth/ui/src/components/base/ButtonStyled.vue'
|
||||
|
||||
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer
|
||||
}>()
|
||||
@@ -242,12 +244,12 @@ const uploadFile = async (e: Event) => {
|
||||
|
||||
try {
|
||||
if (data.value?.image) {
|
||||
await props.server.fs?.deleteFileOrFolder('/server-icon.png', false)
|
||||
await props.server.fs?.deleteFileOrFolder('/server-icon-original.png', false)
|
||||
await client.kyros.files_v0.deleteFileOrFolder('/server-icon.png', false)
|
||||
await client.kyros.files_v0.deleteFileOrFolder('/server-icon-original.png', false)
|
||||
}
|
||||
|
||||
await props.server.fs?.uploadFile('/server-icon.png', scaledFile)
|
||||
await props.server.fs?.uploadFile('/server-icon-original.png', file)
|
||||
await client.kyros.files_v0.uploadFile('/server-icon.png', scaledFile).promise
|
||||
await client.kyros.files_v0.uploadFile('/server-icon-original.png', file).promise
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
@@ -284,8 +286,8 @@ const uploadFile = async (e: Event) => {
|
||||
const resetIcon = async () => {
|
||||
if (data.value?.image) {
|
||||
try {
|
||||
await props.server.fs?.deleteFileOrFolder('/server-icon.png', false)
|
||||
await props.server.fs?.deleteFileOrFolder('/server-icon-original.png', false)
|
||||
await client.kyros.files_v0.deleteFileOrFolder('/server-icon.png', false)
|
||||
await client.kyros.files_v0.deleteFileOrFolder('/server-icon-original.png', false)
|
||||
|
||||
useState(`server-icon-${props.server.serverId}`).value = undefined
|
||||
if (data.value) data.value.image = undefined
|
||||
|
||||
@@ -78,11 +78,6 @@ const preferences = {
|
||||
description: 'When enabled, you will be prompted before stopping and restarting your server.',
|
||||
implemented: true,
|
||||
},
|
||||
backupWhileRunning: {
|
||||
displayName: 'Create backups while running',
|
||||
description: 'When enabled, backups will be created even if the server is running.',
|
||||
implemented: true,
|
||||
},
|
||||
} as const
|
||||
|
||||
type PreferenceKeys = keyof typeof preferences
|
||||
@@ -96,7 +91,6 @@ const defaultPreferences: UserPreferences = {
|
||||
hideSubdomainLabel: false,
|
||||
autoRestart: false,
|
||||
powerDontAskAgain: false,
|
||||
backupWhileRunning: false,
|
||||
}
|
||||
|
||||
const userPreferences = useStorage<UserPreferences>(
|
||||
|
||||
@@ -1,32 +1,7 @@
|
||||
<template>
|
||||
<div class="relative h-full w-full select-none overflow-y-auto">
|
||||
<div
|
||||
v-if="server.moduleErrors.fs"
|
||||
class="flex w-full flex-col items-center justify-center gap-4 p-4"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<IssuesIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load properties</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
We couldn't access your server's properties. Here's what we know:
|
||||
<span class="break-all font-mono">{{
|
||||
JSON.stringify(server.moduleErrors.fs.error)
|
||||
}}</span>
|
||||
</p>
|
||||
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['fs'])">
|
||||
<button class="mt-6 !w-full">Retry</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="propsData && status === 'success'"
|
||||
v-if="propsData && status === 'success'"
|
||||
class="flex h-full w-full flex-col justify-between gap-6 overflow-y-auto"
|
||||
>
|
||||
<div class="card flex flex-col gap-4">
|
||||
@@ -158,8 +133,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EyeIcon, IssuesIcon, SearchIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, Combobox, injectNotificationManager } from '@modrinth/ui'
|
||||
import { EyeIcon, SearchIcon } from '@modrinth/assets'
|
||||
import { Combobox, injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
|
||||
import Fuse from 'fuse.js'
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
|
||||
@@ -167,6 +142,8 @@ import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer
|
||||
}>()
|
||||
@@ -181,33 +158,39 @@ const data = computed(() => props.server.general)
|
||||
const modulesLoaded = inject<Promise<void>>('modulesLoaded')
|
||||
const { data: propsData, status } = await useAsyncData('ServerProperties', async () => {
|
||||
await modulesLoaded
|
||||
const rawProps = await props.server.fs?.downloadFile('server.properties')
|
||||
if (!rawProps) return null
|
||||
try {
|
||||
const blob = await client.kyros.files_v0.downloadFile('/server.properties')
|
||||
const rawProps = await blob.text()
|
||||
if (!rawProps) return null
|
||||
|
||||
const properties: Record<string, any> = {}
|
||||
const lines = rawProps.split('\n')
|
||||
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('=')
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('#') || !line.includes('=')) continue
|
||||
const [key, ...valueParts] = line.split('=')
|
||||
const rawValue = valueParts.join('=')
|
||||
let value: string | boolean | number = rawValue
|
||||
|
||||
if (value.toLowerCase() === 'true' || value.toLowerCase() === 'false') {
|
||||
value = value.toLowerCase() === 'true'
|
||||
} else {
|
||||
const intLike = /^[-+]?\d+$/.test(value)
|
||||
if (intLike) {
|
||||
const n = Number(value)
|
||||
if (Number.isSafeInteger(n)) {
|
||||
value = n
|
||||
if (rawValue.toLowerCase() === 'true' || rawValue.toLowerCase() === 'false') {
|
||||
value = rawValue.toLowerCase() === 'true'
|
||||
} else {
|
||||
const intLike = /^[-+]?\d+$/.test(rawValue)
|
||||
if (intLike) {
|
||||
const n = Number(rawValue)
|
||||
if (Number.isSafeInteger(n)) {
|
||||
value = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
properties[key.trim()] = value
|
||||
}
|
||||
|
||||
properties[key.trim()] = value
|
||||
return properties
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
return properties
|
||||
})
|
||||
|
||||
const liveProperties = ref<Record<string, any>>({})
|
||||
@@ -302,7 +285,7 @@ const constructServerProperties = (): string => {
|
||||
const saveProperties = async () => {
|
||||
try {
|
||||
isUpdating.value = true
|
||||
await props.server.fs?.updateFile('server.properties', constructServerProperties())
|
||||
await client.kyros.files_v0.updateFile('/server.properties', constructServerProperties())
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
originalProperties.value = JSON.parse(JSON.stringify(liveProperties.value))
|
||||
await props.server.refresh()
|
||||
|
||||
Reference in New Issue
Block a user