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,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)
}