feat: ws client & new backups frontend (#4813)

* feat: ws client

* feat: v1 backups endpoints

* feat: migrate backups page to api-client and new DI ctx

* feat: switch to ws client via api-client

* fix: disgust

* fix: stats

* fix: console

* feat: v0 backups api

* feat: migrate backups.vue to page system w/ components to ui pkgs

* feat: polish backups frontend

* feat: pending refactor for ws handling of backups

* fix: vue shit

* fix: cancel logic fix

* fix: qa issues

* fix: alignment issues for backups page

* fix: bar positioning

* feat: finish QA

* fix: icons

* fix: lint & i18n

* fix: clear comment

* lint

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2025-12-04 02:32:03 +00:00
committed by GitHub
parent e3444a3456
commit 8eff939039
43 changed files with 2466 additions and 1177 deletions

View File

@@ -1,126 +0,0 @@
<template>
<NewModal ref="modal" header="Creating backup" @show="focusInput">
<div class="flex flex-col gap-2 md:w-[600px]">
<label for="backup-name-input">
<span class="text-lg font-semibold text-contrast"> Name </span>
</label>
<input
id="backup-name-input"
ref="input"
v-model="backupName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
:placeholder="`Backup #${newBackupAmount}`"
maxlength="48"
/>
<div v-if="nameExists && !isCreating" class="flex items-center gap-1">
<IssuesIcon class="hidden text-orange sm:block" />
<span class="text-sm text-orange">
You already have a backup named '<span class="font-semibold">{{ trimmedName }}</span
>'
</span>
</div>
<div v-if="isRateLimited" class="mt-2 text-sm text-red">
You're creating backups too fast. Please wait a moment before trying again.
</div>
</div>
<div class="mt-2 flex justify-start gap-2">
<ButtonStyled color="brand">
<button :disabled="isCreating || nameExists" @click="createBackup">
<PlusIcon />
Create backup
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hideModal">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { IssuesIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, NewModal } from '@modrinth/ui'
import { ModrinthServersFetchError, type ServerBackup } from '@modrinth/utils'
import { computed, nextTick, ref } from 'vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const input = ref<HTMLInputElement>()
const isCreating = ref(false)
const isRateLimited = ref(false)
const backupName = ref('')
const newBackupAmount = computed(() =>
props.server.backups?.data?.length === undefined ? 1 : props.server.backups?.data?.length + 1,
)
const trimmedName = computed(() => backupName.value.trim())
const nameExists = computed(() => {
if (!props.server.backups?.data) return false
return props.server.backups.data.some(
(backup: ServerBackup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
)
})
const focusInput = () => {
nextTick(() => {
setTimeout(() => {
input.value?.focus()
}, 100)
})
}
function show() {
backupName.value = ''
isCreating.value = false
modal.value?.show()
}
const hideModal = () => {
modal.value?.hide()
}
const createBackup = async () => {
if (backupName.value.trim().length === 0) {
backupName.value = `Backup #${newBackupAmount.value}`
}
isCreating.value = true
isRateLimited.value = false
try {
await props.server.backups?.create(trimmedName.value)
hideModal()
await props.server.refresh()
} catch (error) {
if (error instanceof ModrinthServersFetchError && error?.statusCode === 429) {
isRateLimited.value = true
addNotification({
type: 'error',
title: 'Error creating backup',
text: "You're creating backups too fast.",
})
} else {
const message = error instanceof Error ? error.message : String(error)
addNotification({ type: 'error', title: 'Error creating backup', text: message })
}
} finally {
isCreating.value = false
}
}
defineExpose({
show,
hide: hideModal,
})
</script>

View File

@@ -1,42 +0,0 @@
<template>
<ConfirmModal
ref="modal"
danger
title="Are you sure you want to delete this backup?"
proceed-label="Delete backup"
:confirmation-text="currentBackup?.name ?? 'null'"
has-to-type
@proceed="emit('delete', currentBackup)"
>
<BackupItem
v-if="currentBackup"
:backup="currentBackup"
preview
class="border-px border-solid border-button-border"
/>
</ConfirmModal>
</template>
<script setup lang="ts">
import { ConfirmModal } from '@modrinth/ui'
import type { Backup } from '@modrinth/utils'
import { ref } from 'vue'
import BackupItem from '~/components/ui/servers/BackupItem.vue'
const emit = defineEmits<{
(e: 'delete', backup: Backup | undefined): void
}>()
const modal = ref<InstanceType<typeof ConfirmModal>>()
const currentBackup = ref<Backup | undefined>(undefined)
function show(backup: Backup) {
currentBackup.value = backup
modal.value?.show()
}
defineExpose({
show,
})
</script>

View File

@@ -1,277 +0,0 @@
<script setup lang="ts">
import {
BotIcon,
DownloadIcon,
EditIcon,
FolderArchiveIcon,
HistoryIcon,
LockIcon,
LockOpenIcon,
MoreVerticalIcon,
RotateCounterClockwiseIcon,
SpinnerIcon,
TrashIcon,
XIcon,
} from '@modrinth/assets'
import { ButtonStyled, commonMessages, OverflowMenu, ProgressBar } from '@modrinth/ui'
import type { Backup } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs'
import { computed } from 'vue'
const flags = useFeatureFlags()
const { formatMessage } = useVIntl()
const emit = defineEmits<{
(e: 'download' | 'rename' | 'restore' | 'lock' | 'retry'): void
(e: 'delete', skipConfirmation?: boolean): void
}>()
const props = withDefaults(
defineProps<{
backup: Backup
preview?: boolean
kyrosUrl?: string
jwt?: string
}>(),
{
preview: false,
kyrosUrl: undefined,
jwt: undefined,
},
)
const backupQueued = computed(
() =>
props.backup.task?.create?.progress === 0 ||
(props.backup.ongoing && !props.backup.task?.create),
)
const automated = computed(() => props.backup.automated)
const failedToCreate = computed(() => props.backup.interrupted)
const inactiveStates = ['failed', 'cancelled']
const creating = computed(() => {
const task = props.backup.task?.create
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
return task
}
if (props.backup.ongoing) {
return {
progress: 0,
state: 'ongoing',
}
}
return undefined
})
const restoring = computed(() => {
const task = props.backup.task?.restore
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
return task
}
return undefined
})
const failedToRestore = computed(() => props.backup.task?.restore?.state === 'failed')
const messages = defineMessages({
locked: {
id: 'servers.backups.item.locked',
defaultMessage: 'Locked',
},
lock: {
id: 'servers.backups.item.lock',
defaultMessage: 'Lock',
},
unlock: {
id: 'servers.backups.item.unlock',
defaultMessage: 'Unlock',
},
restore: {
id: 'servers.backups.item.restore',
defaultMessage: 'Restore',
},
rename: {
id: 'servers.backups.item.rename',
defaultMessage: 'Rename',
},
queuedForBackup: {
id: 'servers.backups.item.queued-for-backup',
defaultMessage: 'Queued for backup',
},
creatingBackup: {
id: 'servers.backups.item.creating-backup',
defaultMessage: 'Creating backup...',
},
restoringBackup: {
id: 'servers.backups.item.restoring-backup',
defaultMessage: 'Restoring from backup...',
},
failedToCreateBackup: {
id: 'servers.backups.item.failed-to-create-backup',
defaultMessage: 'Failed to create backup',
},
failedToRestoreBackup: {
id: 'servers.backups.item.failed-to-restore-backup',
defaultMessage: 'Failed to restore from backup',
},
automated: {
id: 'servers.backups.item.automated',
defaultMessage: 'Automated',
},
retry: {
id: 'servers.backups.item.retry',
defaultMessage: 'Retry',
},
})
</script>
<template>
<div
:class="
preview
? 'grid-cols-[min-content_1fr_1fr] sm:grid-cols-[min-content_3fr_2fr_1fr] md:grid-cols-[auto_3fr_2fr_1fr]'
: 'grid-cols-[min-content_1fr_1fr] sm:grid-cols-[min-content_3fr_2fr_1fr] md:grid-cols-[auto_3fr_2fr_1fr_2fr]'
"
class="grid items-center gap-4 rounded-2xl bg-bg-raised px-4 py-3"
>
<div
class="flex h-12 w-12 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg"
>
<SpinnerIcon
v-if="creating"
class="h-6 w-6 animate-spin"
:class="{ 'text-orange': backupQueued, 'text-green': !backupQueued }"
/>
<FolderArchiveIcon v-else class="h-6 w-6" />
</div>
<div class="col-span-2 flex flex-col gap-1 sm:col-span-1">
<span class="font-bold text-contrast">
{{ backup.name }}
</span>
<div class="flex flex-wrap items-center gap-2 text-sm">
<span v-if="backup.locked" class="flex items-center gap-1 text-sm text-secondary">
<LockIcon /> {{ formatMessage(messages.locked) }}
</span>
<span v-if="automated && backup.locked"></span>
<span v-if="automated" class="flex items-center gap-1 text-secondary">
<BotIcon /> {{ formatMessage(messages.automated) }}
</span>
<span v-if="(failedToCreate || failedToRestore) && (automated || backup.locked)"></span>
<span
v-if="failedToCreate || failedToRestore"
class="flex items-center gap-1 text-sm text-red"
>
<XIcon />
{{
formatMessage(
failedToCreate ? messages.failedToCreateBackup : messages.failedToRestoreBackup,
)
}}
</span>
</div>
</div>
<div v-if="creating" class="col-span-2 flex flex-col gap-3">
<span v-if="backupQueued" class="text-orange">
{{ formatMessage(messages.queuedForBackup) }}
</span>
<span v-else class="text-green"> {{ formatMessage(messages.creatingBackup) }} </span>
<ProgressBar
:progress="creating.progress"
:color="backupQueued ? 'orange' : 'green'"
:waiting="creating.progress === 0"
class="max-w-full"
/>
</div>
<div v-else-if="restoring" class="col-span-2 flex flex-col gap-3 text-purple">
{{ formatMessage(messages.restoringBackup) }}
<ProgressBar
:progress="restoring.progress"
color="purple"
:waiting="restoring.progress === 0"
class="max-w-full"
/>
</div>
<template v-else>
<div class="col-span-2">
{{ dayjs(backup.created_at).format('MMMM D, YYYY [at] h:mm A') }}
</div>
<div v-if="false">{{ 245 }} MiB</div>
</template>
<div
v-if="!preview"
class="col-span-full flex justify-normal gap-2 md:col-span-1 md:justify-end"
>
<template v-if="failedToCreate">
<ButtonStyled>
<button @click="() => emit('retry')">
<RotateCounterClockwiseIcon />
{{ formatMessage(messages.retry) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="() => emit('delete', true)">
<TrashIcon />
Remove
</button>
</ButtonStyled>
</template>
<ButtonStyled v-else-if="creating">
<button @click="() => emit('delete')">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<template v-else>
<ButtonStyled>
<a
:class="{
disabled: !kyrosUrl || !jwt,
}"
:href="`https://${kyrosUrl}/modrinth/v0/backups/${backup.id}/download?auth=${jwt}`"
@click="() => emit('download')"
>
<DownloadIcon />
{{ formatMessage(commonMessages.downloadButton) }}
</a>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
:options="[
{ id: 'rename', action: () => emit('rename') },
{
id: 'restore',
action: () => emit('restore'),
disabled: !!restoring,
},
{ id: 'lock', action: () => emit('lock') },
{ divider: true },
{
id: 'delete',
color: 'red',
action: () => emit('delete'),
disabled: !!restoring,
},
]"
>
<MoreVerticalIcon />
<template #rename> <EditIcon /> {{ formatMessage(messages.rename) }} </template>
<template #restore> <HistoryIcon /> {{ formatMessage(messages.restore) }} </template>
<template v-if="backup.locked" #lock>
<LockOpenIcon /> {{ formatMessage(messages.unlock) }}
</template>
<template v-else #lock> <LockIcon /> {{ formatMessage(messages.lock) }} </template>
<template #delete>
<TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</div>
<pre
v-if="!preview && flags.advancedDebugInfo"
class="col-span-full m-0 rounded-xl bg-button-bg text-xs"
>{{ backup }}</pre
>
</div>
</template>

View File

@@ -1,144 +0,0 @@
<template>
<NewModal ref="modal" header="Renaming backup" @show="focusInput">
<div class="flex flex-col gap-2 md:w-[600px]">
<label for="backup-name-input">
<span class="text-lg font-semibold text-contrast"> Name </span>
</label>
<input
id="backup-name-input"
ref="input"
v-model="backupName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
:placeholder="`Backup #${backupNumber}`"
maxlength="48"
/>
<div v-if="nameExists" class="flex items-center gap-1">
<IssuesIcon class="hidden text-orange sm:block" />
<span class="text-sm text-orange">
You already have a backup named '<span class="font-semibold">{{ trimmedName }}</span
>'
</span>
</div>
</div>
<div class="mt-2 flex justify-start gap-2">
<ButtonStyled color="brand">
<button :disabled="isRenaming || nameExists" @click="renameBackup">
<template v-if="isRenaming">
<SpinnerIcon class="animate-spin" />
Renaming...
</template>
<template v-else>
<SaveIcon />
Save changes
</template>
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { IssuesIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, NewModal } from '@modrinth/ui'
import type { Backup } from '@modrinth/utils'
import { computed, nextTick, ref } from 'vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const input = ref<HTMLInputElement>()
const backupName = ref('')
const originalName = ref('')
const isRenaming = ref(false)
const currentBackup = ref<Backup | null>(null)
const trimmedName = computed(() => backupName.value.trim())
const nameExists = computed(() => {
if (!props.server.backups?.data || trimmedName.value === originalName.value || isRenaming.value) {
return false
}
return props.server.backups.data.some(
(backup: Backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
)
})
const backupNumber = computed(
() => (props.server.backups?.data?.findIndex((b) => b.id === currentBackup.value?.id) ?? 0) + 1,
)
const focusInput = () => {
nextTick(() => {
setTimeout(() => {
input.value?.focus()
}, 100)
})
}
function show(backup: Backup) {
currentBackup.value = backup
backupName.value = backup.name
originalName.value = backup.name
isRenaming.value = false
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
const renameBackup = async () => {
if (!currentBackup.value) {
addNotification({
type: 'error',
title: 'Error renaming backup',
text: 'Current backup is null',
})
return
}
if (trimmedName.value === originalName.value) {
hide()
return
}
isRenaming.value = true
try {
let newName = trimmedName.value
if (newName.length === 0) {
newName = `Backup #${backupNumber.value}`
}
await props.server.backups?.rename(currentBackup.value.id, newName)
hide()
await props.server.refresh()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
addNotification({ type: 'error', title: 'Error renaming backup', text: message })
} finally {
hide()
isRenaming.value = false
}
}
defineExpose({
show,
hide,
})
</script>

View File

@@ -1,63 +0,0 @@
<template>
<ConfirmModal
ref="modal"
danger
title="Are you sure you want to restore from this backup?"
proceed-label="Restore from backup"
description="This will **overwrite all files on your server** and replace them with the files from the backup."
@proceed="restoreBackup"
>
<BackupItem
v-if="currentBackup"
:backup="currentBackup"
preview
class="border-px border-solid border-button-border"
/>
</ConfirmModal>
</template>
<script setup lang="ts">
import type { NewModal } from '@modrinth/ui'
import { ConfirmModal, injectNotificationManager } from '@modrinth/ui'
import type { Backup } from '@modrinth/utils'
import { ref } from 'vue'
import BackupItem from '~/components/ui/servers/BackupItem.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const currentBackup = ref<Backup | null>(null)
function show(backup: Backup) {
currentBackup.value = backup
modal.value?.show()
}
const restoreBackup = async () => {
if (!currentBackup.value) {
addNotification({
type: 'error',
title: 'Failed to restore backup',
text: 'Current backup is null',
})
return
}
try {
await props.server.backups?.restore(currentBackup.value.id)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
addNotification({ type: 'error', title: 'Failed to restore backup', text: message })
}
}
defineExpose({
show,
})
</script>

View File

@@ -2564,42 +2564,6 @@
"servers.backup.restore.in-progress.tooltip": {
"message": "Backup restore in progress"
},
"servers.backups.item.automated": {
"message": "Automated"
},
"servers.backups.item.creating-backup": {
"message": "Creating backup..."
},
"servers.backups.item.failed-to-create-backup": {
"message": "Failed to create backup"
},
"servers.backups.item.failed-to-restore-backup": {
"message": "Failed to restore from backup"
},
"servers.backups.item.lock": {
"message": "Lock"
},
"servers.backups.item.locked": {
"message": "Locked"
},
"servers.backups.item.queued-for-backup": {
"message": "Queued for backup"
},
"servers.backups.item.rename": {
"message": "Rename"
},
"servers.backups.item.restore": {
"message": "Restore"
},
"servers.backups.item.restoring-backup": {
"message": "Restoring from backup..."
},
"servers.backups.item.retry": {
"message": "Retry"
},
"servers.backups.item.unlock": {
"message": "Unlock"
},
"servers.notice.actions": {
"message": "Actions"
},

View File

@@ -341,7 +341,6 @@
:stats="stats"
:server-power-state="serverPowerState"
:power-state-details="powerStateDetails"
:socket="socket"
:server="server"
:backup-in-progress="backupInProgress"
@reinstall="onReinstall"
@@ -362,6 +361,7 @@
<script setup lang="ts">
import { Intercom, shutdown } from '@intercom/messenger-js-sdk'
import type { Archon } from '@modrinth/api-client'
import {
CheckIcon,
CopyIcon,
@@ -376,22 +376,18 @@ import {
import {
ButtonStyled,
ErrorInformationCard,
injectModrinthClient,
injectNotificationManager,
provideModrinthServerContext,
ServerIcon,
ServerInfoLabels,
ServerNotice,
} from '@modrinth/ui'
import type {
Backup,
PowerAction,
ServerState,
Stats,
WSEvent,
WSInstallationResultEvent,
} from '@modrinth/utils'
import type { PowerAction, Stats } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import type { MessageDescriptor } from '@vintl/vintl'
import DOMPurify from 'dompurify'
import { computed, onMounted, onUnmounted, type Reactive, ref } from 'vue'
import { computed, onMounted, onUnmounted, type Reactive, reactive, ref } from 'vue'
import { reloadNuxtApp } from '#app'
import NavTabs from '~/components/ui/NavTabs.vue'
@@ -407,12 +403,12 @@ import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
import { useModrinthServersConsole } from '~/store/console.ts'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const socket = ref<WebSocket | null>(null)
const isReconnecting = ref(false)
const isLoading = ref(true)
const reconnectInterval = ref<ReturnType<typeof setInterval> | null>(null)
const isMounted = ref(true)
const unsubscribers = ref<(() => void)[]>([])
const flags = useFeatureFlags()
const INTERCOM_APP_ID = ref('ykeritl9')
@@ -430,6 +426,12 @@ const route = useNativeRoute()
const router = useRouter()
const serverId = route.params.id as string
// TODO: ditch useModrinthServers for this + ctx DI.
const { data: n_server } = useQuery({
queryKey: ['servers', 'detail', serverId],
queryFn: () => client.archon.servers_v0.get(serverId)!,
})
const server: Reactive<ModrinthServer> = await useModrinthServers(serverId, ['general', 'ws'])
const loadModulesPromise = Promise.resolve().then(() => {
@@ -449,15 +451,32 @@ const serverData = computed(() => server.general)
const isConnected = ref(false)
const isWSAuthIncorrect = ref(false)
const modrinthServersConsole = useModrinthServersConsole()
const queryClient = useQueryClient()
const cpuData = ref<number[]>([])
const ramData = ref<number[]>([])
const isActioning = ref(false)
const isServerRunning = computed(() => serverPowerState.value === 'running')
const serverPowerState = ref<ServerState>('stopped')
const serverPowerState = ref<Archon.Websocket.v0.PowerState>('stopped')
const powerStateDetails = ref<{ oom_killed?: boolean; exit_code?: number }>()
const backupsState = reactive(new Map())
const completedBackupTasks = new Set<string>()
const cancelledBackups = new Set<string>()
const markBackupCancelled = (backupId: string) => {
cancelledBackups.add(backupId)
}
provideModrinthServerContext({
serverId,
server: n_server as Ref<Archon.Servers.v0.Server>,
isConnected,
powerState: serverPowerState,
isServerRunning,
backupsState,
markBackupCancelled,
})
const uptimeSeconds = ref(0)
const firstConnect = ref(true)
const copied = ref(false)
const error = ref<Error | null>(null)
@@ -609,90 +628,6 @@ function showSurvey() {
}
}
const connectWebSocket = () => {
if (!isMounted.value) return
try {
const wsAuth = computed(() => server.ws)
socket.value = new WebSocket(`wss://${wsAuth.value?.url}`)
socket.value.onopen = () => {
if (!isMounted.value) {
socket.value?.close()
return
}
modrinthServersConsole.clear()
socket.value?.send(JSON.stringify({ event: 'auth', jwt: wsAuth.value?.token }))
isConnected.value = true
isReconnecting.value = false
isLoading.value = false
if (firstConnect.value) {
for (let i = 0; i < initialConsoleMessage.length; i++) {
modrinthServersConsole.addLine(initialConsoleMessage[i])
}
}
firstConnect.value = false
if (reconnectInterval.value) {
if (reconnectInterval.value !== null) {
clearInterval(reconnectInterval.value)
}
reconnectInterval.value = null
}
}
socket.value.onmessage = (event) => {
if (isMounted.value) {
const data: WSEvent = JSON.parse(event.data)
handleWebSocketMessage(data)
}
}
socket.value.onclose = () => {
if (isMounted.value) {
modrinthServersConsole.addLine(
"\nSomething went wrong with the connection, we're reconnecting...",
)
isConnected.value = false
scheduleReconnect()
}
}
socket.value.onerror = (error) => {
if (isMounted.value) {
console.error('Failed to connect WebSocket:', error)
isConnected.value = false
scheduleReconnect()
}
}
} catch (error) {
if (isMounted.value) {
console.error('Failed to connect WebSocket:', error)
isConnected.value = false
scheduleReconnect()
}
}
}
const scheduleReconnect = () => {
if (!isMounted.value) return
if (!reconnectInterval.value) {
isReconnecting.value = true
reconnectInterval.value = setInterval(() => {
if (isMounted.value) {
console.log('Attempting to reconnect...')
connectWebSocket()
} else {
reconnectInterval.value = null
}
}, 5000)
}
}
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
const startUptimeUpdates = () => {
@@ -707,113 +642,153 @@ const stopUptimeUpdates = () => {
}
}
const handleWebSocketMessage = (data: WSEvent) => {
switch (data.event) {
case 'log':
// eslint-disable-next-line no-case-declarations
const log = data.message.split('\n').filter((l) => l.trim())
modrinthServersConsole.addLines(log)
break
case 'stats':
updateStats(data)
break
case 'auth-expiring':
case 'auth-incorrect':
reauthenticate()
break
case 'power-state':
if (data.state === 'crashed') {
updatePowerState(data.state, {
oom_killed: data.oom_killed,
exit_code: data.exit_code,
})
} else {
updatePowerState(data.state)
}
break
case 'installation-result':
handleInstallationResult(data)
break
case 'new-mod':
server.refresh(['content'])
console.log('New mod:', data)
break
case 'auth-ok':
break
case 'uptime':
stopUptimeUpdates()
uptimeSeconds.value = data.uptime
startUptimeUpdates()
break
case 'backup-progress': {
// Update a backup's state
const curBackup = server.backups?.data.find((backup) => backup.id === data.id)
const handleLog = (data: Archon.Websocket.v0.WSLogEvent) => {
const log = data.message.split('\n').filter((l) => l.trim())
modrinthServersConsole.addLines(log)
}
if (!curBackup) {
console.log(`Ignoring backup-progress event for unknown backup: ${data.id}`)
} else {
console.log(
`Handling backup progress for ${curBackup.name} (${data.id}) task: ${data.task} state: ${data.state} progress: ${data.progress}`,
)
const handleStats = (data: Archon.Websocket.v0.WSStatsEvent) => {
updateStats({
cpu_percent: data.cpu_percent,
ram_usage_bytes: data.ram_usage_bytes,
ram_total_bytes: data.ram_total_bytes,
storage_usage_bytes: data.storage_usage_bytes,
storage_total_bytes: data.storage_total_bytes,
})
}
if (!curBackup.task) {
curBackup.task = {}
const handlePowerState = (data: Archon.Websocket.v0.WSPowerStateEvent) => {
if (data.state === 'crashed') {
updatePowerState(data.state, {
oom_killed: data.oom_killed,
exit_code: data.exit_code,
})
} else {
updatePowerState(data.state)
}
}
const handleUptime = (data: Archon.Websocket.v0.WSUptimeEvent) => {
stopUptimeUpdates()
uptimeSeconds.value = data.uptime
startUptimeUpdates()
}
const handleAuthIncorrect = () => {
isWSAuthIncorrect.value = true
}
const handleBackupProgress = (data: Archon.Websocket.v0.WSBackupProgressEvent) => {
// Ignore 'file' task events - these are per-file progress updates sent continuously
if (data.task === 'file') {
return
}
const backupId = data.id
const taskKey = `${backupId}:${data.task}`
if (completedBackupTasks.has(taskKey)) {
return
}
if (cancelledBackups.has(backupId)) {
return
}
const current = backupsState.get(backupId) ?? {}
const previousState = current[data.task]?.state
const previousProgress = current[data.task]?.progress
if (previousState !== data.state || previousProgress !== data.progress) {
// (mutating same reference doesn't work)
backupsState.set(backupId, {
...current,
[data.task]: {
progress: data.progress,
state: data.state,
},
})
}
const isTerminalState =
data.state === 'done' || data.state === 'failed' || data.state === 'cancelled'
if (isTerminalState) {
completedBackupTasks.add(taskKey)
const attemptCleanup = (attempt: number = 1) => {
queryClient.invalidateQueries({ queryKey: ['backups', 'list', serverId] }).then(() => {
const backupData = queryClient.getQueryData<Archon.Backups.v1.Backup[]>([
'backups',
'list',
serverId,
])
const backup = backupData?.find((b) => b.id === backupId)
if (backup?.ongoing && attempt < 3) {
// retry 3 times max, archon is slow compared to ws state
// jank as hell
setTimeout(() => attemptCleanup(attempt + 1), 1000)
return
}
const currentState = curBackup.task[data.task]?.state
const shouldUpdate = !(currentState === 'ongoing' && data.state === 'unchanged')
if (shouldUpdate) {
curBackup.task[data.task] = {
progress: data.progress,
state: data.state,
// clean up on success/3 attempts failed hope and pray
const entry = backupsState.get(backupId)
if (entry) {
const { [data.task]: _, ...remaining } = entry
if (Object.keys(remaining).length === 0) {
backupsState.delete(backupId)
} else {
backupsState.set(backupId, remaining)
}
}
curBackup.ongoing = data.task === 'create' && data.state === 'ongoing'
}
break
})
}
case 'filesystem-ops': {
if (!server.fs) {
console.error('FilesystemOps received, but server.fs is not available', data.all)
break
}
if (JSON.stringify(server.fs.ops) !== JSON.stringify(data.all)) {
server.fs.ops = data.all
}
server.fs.queuedOps = server.fs.queuedOps.filter(
(queuedOp) => !data.all.some((x) => x.src === queuedOp.src),
)
const cancelled = data.all.filter((x) => x.state === 'cancelled')
Promise.all(cancelled.map((x) => server.fs?.modifyOp(x.id, 'dismiss')))
const completed = data.all.filter((x) => x.state === 'done')
if (completed.length > 0) {
setTimeout(
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')
}
return Promise.resolve()
}),
),
3000,
)
}
break
}
default:
console.warn('Unhandled WebSocket event:', data)
attemptCleanup()
}
}
const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) => {
if (!server.fs) {
console.error('FilesystemOps received, but server.fs is not available', data)
return
}
const allOps = data.all
if (JSON.stringify(server.fs.ops) !== JSON.stringify(allOps)) {
server.fs.ops = allOps as unknown as ModrinthServer['fs']['ops']
}
server.fs.queuedOps = server.fs.queuedOps.filter(
(queuedOp) => !allOps.some((x) => x.src === queuedOp.src),
)
const cancelled = allOps.filter((x) => x.state === 'cancelled')
Promise.all(cancelled.map((x) => server.fs?.modifyOp(x.id, 'dismiss')))
const completed = allOps.filter((x) => x.state === 'done')
if (completed.length > 0) {
setTimeout(
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')
}
return Promise.resolve()
}),
),
3000,
)
}
}
const handleNewMod = () => {
server.refresh(['content'])
}
const newLoader = ref<string | null>(null)
const newLoaderVersion = ref<string | null>(null)
const newMCVersion = ref<string | null>(null)
@@ -842,7 +817,7 @@ const onReinstall = (potentialArgs: any) => {
errorMessage.value = 'An unexpected error occurred.'
}
const handleInstallationResult = async (data: WSInstallationResultEvent) => {
const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallationResultEvent) => {
switch (data.result) {
case 'ok': {
if (!serverData.value) break
@@ -929,7 +904,7 @@ const updateStats = (currentStats: Stats['current']) => {
}
const updatePowerState = (
state: ServerState,
state: Archon.Websocket.v0.PowerState,
details?: { oom_killed?: boolean; exit_code?: number },
) => {
serverPowerState.value = state
@@ -952,17 +927,6 @@ const updateGraphData = (dataArray: number[], newValue: number): number[] => {
return updated
}
const reauthenticate = async () => {
try {
await server.refresh()
const wsAuth = computed(() => server.ws)
socket.value?.send(JSON.stringify({ event: 'auth', jwt: wsAuth.value?.token }))
} catch (error) {
console.error('Reauthentication failed:', error)
isWSAuthIncorrect.value = true
}
}
const toAdverb = (word: string) => {
if (word.endsWith('p')) {
return word + 'ping'
@@ -1022,15 +986,13 @@ const CreateInProgressReason = {
} satisfies BackupInProgressReason
const backupInProgress = computed(() => {
const backups = server.backups?.data
if (!backups) {
return undefined
}
if (backups.find((backup: Backup) => backup?.task?.create?.state === 'ongoing')) {
return CreateInProgressReason
}
if (backups.find((backup: Backup) => backup?.task?.restore?.state === 'ongoing')) {
return RestoreInProgressReason
for (const entry of backupsState.values()) {
if (entry.create?.state === 'ongoing') {
return CreateInProgressReason
}
if (entry.restore?.state === 'ongoing') {
return RestoreInProgressReason
}
}
return undefined
})
@@ -1150,30 +1112,19 @@ const cleanup = () => {
shutdown()
stopUptimeUpdates()
if (reconnectInterval.value) {
clearInterval(reconnectInterval.value)
reconnectInterval.value = null
}
if (socket.value) {
socket.value.onopen = null
socket.value.onmessage = null
socket.value.onclose = null
socket.value.onerror = null
unsubscribers.value.forEach((unsub) => unsub())
unsubscribers.value = []
if (
socket.value.readyState === WebSocket.OPEN ||
socket.value.readyState === WebSocket.CONNECTING
) {
socket.value.close()
}
socket.value = null
}
client.archon.sockets.disconnect(serverId)
isConnected.value = false
isReconnecting.value = false
isLoading.value = true
completedBackupTasks.clear()
cancelledBackups.clear()
DOMPurify.removeHook('afterSanitizeAttributes')
}
@@ -1216,7 +1167,35 @@ onMounted(() => {
if (server.moduleErrors.general?.error) {
isLoading.value = false
} else {
connectWebSocket()
client.archon.sockets
.safeConnect(serverId)
.then(() => {
modrinthServersConsole.clear()
isConnected.value = true
isLoading.value = false
for (const line of initialConsoleMessage) {
modrinthServersConsole.addLine(line)
}
unsubscribers.value = [
client.archon.sockets.on(serverId, 'log', handleLog),
client.archon.sockets.on(serverId, 'stats', handleStats),
client.archon.sockets.on(serverId, 'power-state', handlePowerState),
client.archon.sockets.on(serverId, 'uptime', handleUptime),
client.archon.sockets.on(serverId, 'auth-incorrect', handleAuthIncorrect),
client.archon.sockets.on(serverId, 'auth-ok', () => {}),
client.archon.sockets.on(serverId, 'installation-result', handleInstallationResult),
client.archon.sockets.on(serverId, 'backup-progress', handleBackupProgress),
client.archon.sockets.on(serverId, 'filesystem-ops', handleFilesystemOps),
client.archon.sockets.on(serverId, 'new-mod', handleNewMod),
]
})
.catch((error) => {
console.error('Failed to connect WebSocket:', error)
isConnected.value = false
isLoading.value = false
})
}
if (server.general?.flows?.intro && server.general?.project) {

View File

@@ -1,334 +1,17 @@
<template>
<div
v-if="server.moduleErrors.backups"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load backups</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's backups. Here's what went wrong:
</p>
<p>
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.backups.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['backups'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="data" class="contents">
<BackupCreateModal ref="createBackupModal" :server="server" />
<BackupRenameModal ref="renameBackupModal" :server="server" />
<BackupRestoreModal ref="restoreBackupModal" :server="server" />
<BackupDeleteModal ref="deleteBackupModal" :server="server" @delete="deleteBackup" />
<BackupSettingsModal ref="backupSettingsModal" :server="server" />
<div class="mb-6 flex flex-col items-center justify-between gap-4 sm:flex-row">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<h1 class="m-0 text-2xl font-extrabold text-contrast">Backups</h1>
<TagItem
v-tooltip="`${data.backup_quota - data.used_backup_quota} backup slots remaining`"
class="cursor-help"
:style="{
'--_color':
data.backup_quota <= data.used_backup_quota
? 'var(--color-red)'
: data.backup_quota - data.used_backup_quota <= 3
? 'var(--color-orange)'
: undefined,
'--_bg-color':
data.backup_quota <= data.used_backup_quota
? 'var(--color-red-bg)'
: data.backup_quota - data.used_backup_quota <= 3
? 'var(--color-orange-bg)'
: undefined,
}"
>
{{ data.used_backup_quota }} / {{ data.backup_quota }}
</TagItem>
</div>
<p class="m-0">
You can have up to {{ data.backup_quota }} backups at once, stored securely off-site.
</p>
</div>
<div
class="grid w-full grid-cols-[repeat(auto-fit,_minmax(180px,1fr))] gap-2 sm:flex sm:w-fit sm:flex-row"
>
<ButtonStyled type="standard">
<button
v-tooltip="
'Auto backups are currently unavailable; we apologize for the inconvenience.'
"
:disabled="true || server.general?.status === 'installing'"
@click="showbackupSettingsModal"
>
<SettingsIcon class="h-5 w-5" />
Auto backups
</button>
</ButtonStyled>
<ButtonStyled type="standard" color="brand">
<button
v-tooltip="backupCreationDisabled"
class="w-full sm:w-fit"
:disabled="!!backupCreationDisabled"
@click="showCreateModel"
>
<PlusIcon class="h-5 w-5" />
Create backup
</button>
</ButtonStyled>
</div>
</div>
<div class="flex w-full flex-col gap-2">
<div
v-if="backups.length === 0"
class="mt-6 flex items-center justify-center gap-2 text-center text-secondary"
>
<template v-if="data.used_backup_quota">
<SpinnerIcon class="animate-spin" />
Loading backups...
</template>
<template v-else> You don't have any backups yet. </template>
</div>
<BackupItem
v-for="backup in backups"
:key="`backup-${backup.id}`"
:backup="backup"
:kyros-url="props.server.general?.node.instance"
:jwt="props.server.general?.node.token"
@download="() => triggerDownloadAnimation()"
@rename="() => renameBackupModal?.show(backup)"
@restore="() => restoreBackupModal?.show(backup)"
@lock="
() => {
if (backup.locked) {
unlockBackup(backup.id)
} else {
lockBackup(backup.id)
}
}
"
@delete="
(skipConfirmation?: boolean) =>
!skipConfirmation ? deleteBackup(backup) : deleteBackupModal?.show(backup)
"
@retry="() => retryBackup(backup.id)"
/>
</div>
<div
class="over-the-top-download-animation"
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
>
<div>
<div
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
></div>
<div
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
></div>
<div
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
>
<DownloadIcon class="h-20 w-20 text-contrast" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { DownloadIcon, IssuesIcon, PlusIcon, SettingsIcon, SpinnerIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, TagItem } from '@modrinth/ui'
import type { Backup } from '@modrinth/utils'
import { useStorage } from '@vueuse/core'
import { computed, ref } from 'vue'
import { injectModrinthServerContext, ServersManageBackupsPage } from '@modrinth/ui'
import BackupCreateModal from '~/components/ui/servers/BackupCreateModal.vue'
import BackupDeleteModal from '~/components/ui/servers/BackupDeleteModal.vue'
import BackupItem from '~/components/ui/servers/BackupItem.vue'
import BackupRenameModal from '~/components/ui/servers/BackupRenameModal.vue'
import BackupRestoreModal from '~/components/ui/servers/BackupRestoreModal.vue'
import BackupSettingsModal from '~/components/ui/servers/BackupSettingsModal.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer
isServerRunning: boolean
}>()
const route = useNativeRoute()
const serverId = route.params.id
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
backupWhileRunning: false,
})
defineEmits(['onDownload'])
const data = computed(() => props.server.general)
const backups = computed(() => {
if (!props.server.backups?.data) return []
return [...props.server.backups.data].sort((a, b) => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
})
const { server, isServerRunning } = injectModrinthServerContext()
const flags = useFeatureFlags()
useHead({
title: `Backups - ${data.value?.name ?? 'Server'} - Modrinth`,
title: `Backups - ${server.value.name ?? 'Server'} - Modrinth`,
})
const overTheTopDownloadAnimation = ref()
const createBackupModal = ref<InstanceType<typeof BackupCreateModal>>()
const renameBackupModal = ref<InstanceType<typeof BackupRenameModal>>()
const restoreBackupModal = ref<InstanceType<typeof BackupRestoreModal>>()
const deleteBackupModal = ref<InstanceType<typeof BackupDeleteModal>>()
const backupSettingsModal = ref<InstanceType<typeof BackupSettingsModal>>()
const backupCreationDisabled = computed(() => {
if (props.isServerRunning && !userPreferences.value.backupWhileRunning) {
return 'Cannot create backup while server is running'
}
if (
data.value?.used_backup_quota !== undefined &&
data.value?.backup_quota !== undefined &&
data.value?.used_backup_quota >= data.value?.backup_quota
) {
return `All ${data.value.backup_quota} of your backup slots are in use`
}
if (backups.value.some((backup) => backup.task?.create?.state === 'ongoing')) {
return 'A backup is already in progress'
}
if (props.server.general?.status === 'installing') {
return 'Cannot create backup while server is installing'
}
return undefined
})
const showCreateModel = () => {
createBackupModal.value?.show()
}
const showbackupSettingsModal = () => {
backupSettingsModal.value?.show()
}
function triggerDownloadAnimation() {
overTheTopDownloadAnimation.value = true
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500)
}
const lockBackup = async (backupId: string) => {
try {
await props.server.backups?.lock(backupId)
await props.server.refresh(['backups'])
} catch (error) {
console.error('Failed to toggle lock:', error)
}
}
const unlockBackup = async (backupId: string) => {
try {
await props.server.backups?.unlock(backupId)
await props.server.refresh(['backups'])
} catch (error) {
console.error('Failed to toggle lock:', error)
}
}
const retryBackup = async (backupId: string) => {
try {
await props.server.backups?.retry(backupId)
await props.server.refresh(['backups'])
} catch (error) {
console.error('Failed to retry backup:', error)
}
}
async function deleteBackup(backup?: Backup) {
if (!backup) {
addNotification({
type: 'error',
title: 'Error deleting backup',
text: 'Backup is null',
})
return
}
try {
await props.server.backups?.delete(backup.id)
await props.server.refresh()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
addNotification({
type: 'error',
title: 'Error deleting backup',
text: message,
})
}
}
</script>
<style scoped>
.over-the-top-download-animation {
position: fixed;
z-index: 100;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
scale: 0.5;
transition: all 0.5s ease-out;
opacity: 1;
&.animation-hidden {
scale: 0.8;
opacity: 0;
.animation-ring-1 {
width: 25rem;
height: 25rem;
}
.animation-ring-2 {
width: 50rem;
height: 50rem;
}
.animation-ring-3 {
width: 100rem;
height: 100rem;
}
}
> div {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: fit-content;
height: fit-content;
> * {
position: absolute;
scale: 1;
transition: all 0.2s ease-out;
width: 20rem;
height: 20rem;
}
}
}
</style>
<template>
<ServersManageBackupsPage
:is-server-running="isServerRunning"
:show-debug-info="flags.advancedDebugInfo"
/>
</template>

View File

@@ -182,7 +182,7 @@
<script setup lang="ts">
import { IssuesIcon, TerminalSquareIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import { ButtonStyled, injectModrinthClient } from '@modrinth/ui'
import type { ServerState, Stats } from '@modrinth/utils'
import PanelServerStatus from '~/components/ui/servers/PanelServerStatus.vue'
@@ -191,7 +191,6 @@ import ServerStats from '~/components/ui/servers/ServerStats.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
type ServerProps = {
socket: WebSocket | null
isConnected: boolean
isWsAuthIncorrect: boolean
stats: Stats
@@ -288,11 +287,8 @@ if (props.serverPowerState === 'crashed' && !props.powerStateDetails?.oom_killed
inspectError()
}
const socket = ref(props.socket)
watch(props, (newAttrs) => {
socket.value = newAttrs.socket
})
const client = injectModrinthClient()
const serverId = props.server.serverId
const DYNAMIC_ARG = Symbol('DYNAMIC_ARG')
@@ -655,7 +651,7 @@ const getSuggestions = (input: string): string[] => {
const sendCommand = () => {
const cmd = commandInput.value.trim()
if (!socket.value || !cmd) return
if (!props.isConnected || !cmd) return
try {
sendConsoleCommand(cmd)
commandInput.value = ''
@@ -668,7 +664,7 @@ const sendCommand = () => {
const sendConsoleCommand = (cmd: string) => {
try {
socket.value?.send(JSON.stringify({ event: 'command', cmd }))
client.archon.sockets.send(serverId, { event: 'command', cmd })
} catch (error) {
console.error('Error sending command:', error)
}