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

@@ -113,7 +113,7 @@
</template>
<script setup lang="ts">
import type { Archon, ProjectV2 } from '@modrinth/api-client'
import type { Archon } from '@modrinth/api-client'
import {
ChevronRightIcon,
LoaderCircleIcon,
@@ -144,7 +144,22 @@ export type PendingChange = {
verb: string
}
const props = defineProps<Partial<Archon.Servers.v0.Server> & { pendingChange?: PendingChange }>()
type ServerListingProps = {
server_id: string
name: string
status: Archon.Servers.v0.Status
suspension_reason?: Archon.Servers.v0.SuspensionReason | null
game?: Archon.Servers.v0.Game
mc_version?: string | null
loader?: Archon.Servers.v0.Loader | null
loader_version?: string | null
net?: Archon.Servers.v0.Net
upstream?: Archon.Servers.v0.Upstream | null
flows?: Archon.Servers.v0.Flows
pendingChange?: PendingChange
}
const props = defineProps<ServerListingProps>()
const { archon, kyros, labrinth } = injectModrinthClient()
@@ -153,7 +168,7 @@ const showLoaderLabel = computed(() => !!props.loader)
const { data: projectData } = useQuery({
queryKey: ['project', props.upstream?.project_id] as const,
queryFn: async (): Promise<ProjectV2 | null> => {
queryFn: async () => {
if (!props.upstream?.project_id) return null
return await labrinth.projects_v2.get(props.upstream.project_id)
},

View File

@@ -0,0 +1,158 @@
<template>
<NewModal ref="modal" header="Create backup" @show="focusInput">
<div class="flex flex-col gap-2 md:w-[600px] -mb-2">
<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="w-full rounded-lg bg-bg-input p-4"
:placeholder="`Backup #${newBackupAmount}`"
maxlength="48"
/>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-20"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-20"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="nameExists && !createMutation.isPending.value"
class="flex items-center gap-1 mt-2 overflow-hidden"
>
<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>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-20"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-20"
leave-to-class="opacity-0 max-h-0"
>
<div v-if="isRateLimited" class="overflow-hidden text-sm text-red">
You're creating backups too fast. Please wait a moment before trying again.
</div>
</Transition>
</div>
<template #actions>
<div class="w-full flex flex-row gap-2 justify-end">
<ButtonStyled type="outlined">
<button class="!border-[1px] !border-surface-4" @click="hideModal">
<XIcon />
Cancel
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="createMutation.isPending.value || nameExists" @click="createBackup">
<PlusIcon />
Create backup
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { IssuesIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref } from 'vue'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
import ButtonStyled from '../../base/ButtonStyled.vue'
import NewModal from '../../modal/NewModal.vue'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const ctx = injectModrinthServerContext()
const props = defineProps<{
backups?: Archon.Backups.v1.Backup[]
}>()
const backupsQueryKey = ['backups', 'list', ctx.serverId]
const createMutation = useMutation({
mutationFn: (name: string) => client.archon.backups_v0.create(ctx.serverId, { name }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
})
const modal = ref<InstanceType<typeof NewModal>>()
const input = ref<HTMLInputElement>()
const isRateLimited = ref(false)
const backupName = ref('')
const newBackupAmount = computed(() => (props.backups?.length ?? 0) + 1)
const trimmedName = computed(() => backupName.value.trim())
const nameExists = computed(() => {
if (!props.backups) return false
return props.backups.some(
(backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
)
})
const focusInput = () => {
nextTick(() => {
setTimeout(() => {
input.value?.focus()
}, 100)
})
}
function show() {
backupName.value = ''
isRateLimited.value = false
modal.value?.show()
}
const hideModal = () => {
modal.value?.hide()
}
const createBackup = () => {
const name = trimmedName.value || `Backup #${newBackupAmount.value}`
isRateLimited.value = false
createMutation.mutate(name, {
onSuccess: () => {
hideModal()
},
onError: (error) => {
if (error instanceof Error && error.message.includes('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 })
}
},
})
}
defineExpose({
show,
hide: hideModal,
})
</script>

View File

@@ -0,0 +1,67 @@
<template>
<NewModal ref="modal" header="Delete backup" fade="danger">
<div class="flex flex-col gap-6">
<Admonition type="critical" header="Delete warning">
This backup will be permanently deleted. This action cannot be undone.
</Admonition>
<div v-if="currentBackup" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Backup</span>
<BackupItem
:backup="currentBackup"
preview
class="!bg-surface-2 border-solid border-[1px] border-surface-5"
/>
</div>
</div>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled>
<button @click="modal?.hide()">
<XIcon />
Cancel
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="deleteBackup">
<TrashIcon />
Delete backup
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { TrashIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import Admonition from '../../base/Admonition.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import NewModal from '../../modal/NewModal.vue'
import BackupItem from './BackupItem.vue'
const emit = defineEmits<{
(e: 'delete', backup: Archon.Backups.v1.Backup | undefined): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const currentBackup = ref<Archon.Backups.v1.Backup>()
function show(backup: Archon.Backups.v1.Backup) {
currentBackup.value = backup
modal.value?.show()
}
function deleteBackup() {
modal.value?.hide()
emit('delete', currentBackup.value)
}
defineExpose({
show,
})
</script>

View File

@@ -0,0 +1,363 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import {
ClockIcon,
DownloadIcon,
EditIcon,
LockIcon,
LockOpenIcon,
MoreVerticalIcon,
RotateCounterClockwiseIcon,
ShieldIcon,
TrashIcon,
UserRoundIcon,
XIcon,
} from '@modrinth/assets'
import { defineMessages, useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs'
import { computed } from 'vue'
import { commonMessages } from '../../../utils'
import ButtonStyled from '../../base/ButtonStyled.vue'
import OverflowMenu, { type Option as OverflowOption } from '../../base/OverflowMenu.vue'
import ProgressBar from '../../base/ProgressBar.vue'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
(e: 'download' | 'rename' | 'restore' | 'lock' | 'retry'): void
(e: 'delete', skipConfirmation?: boolean): void
}>()
const props = withDefaults(
defineProps<{
backup: Archon.Backups.v1.Backup
preview?: boolean
kyrosUrl?: string
jwt?: string
showDebugInfo?: boolean
disabled?: string
}>(),
{
preview: false,
kyrosUrl: undefined,
jwt: undefined,
showDebugInfo: false,
disabled: 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', 'done']
const creating = computed(() => {
const task = props.backup.task?.create
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
return task
}
if (props.backup.ongoing && !props.backup.task?.restore) {
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
}
if (props.backup.ongoing && props.backup.task?.restore) {
return {
progress: 0,
state: 'ongoing',
}
}
return undefined
})
const restoreQueued = computed(() => restoring.value?.progress === 0)
const failedToRestore = computed(() => props.backup.task?.restore?.state === 'failed')
const backupIcon = computed(() => {
if (props.backup.automated) {
return props.backup.locked ? ShieldIcon : ClockIcon
}
return UserRoundIcon
})
const overflowMenuOptions = computed<OverflowOption[]>(() => {
const options: OverflowOption[] = []
// Download only available when not creating
if (!creating.value) {
options.push({
id: 'download',
action: () => emit('download'),
link: `https://${props.kyrosUrl}/modrinth/v0/backups/${props.backup.id}/download?auth=${props.jwt}`,
disabled: !props.kyrosUrl || !props.jwt,
})
}
options.push({ id: 'rename', action: () => emit('rename') })
options.push({ id: 'lock', action: () => emit('lock') })
// Delete only available when not creating (has separate Cancel button)
if (!creating.value) {
options.push({ divider: true })
options.push({
id: 'delete',
color: 'red',
action: () => emit('delete'),
disabled: !!props.disabled,
})
}
return options
})
// TODO: Uncomment when API supports size field
// const formatBytes = (bytes?: number) => {
// if (!bytes) return ''
// const mb = bytes / (1024 * 1024)
// return `${mb.toFixed(0)} MiB`
// }
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: 'Backup queued',
},
queuedForRestore: {
id: 'servers.backups.item.queued-for-restore',
defaultMessage: 'Restore queued',
},
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',
},
auto: {
id: 'servers.backups.item.auto',
defaultMessage: 'Auto',
},
backupSchedule: {
id: 'servers.backups.item.backup-schedule',
defaultMessage: 'Backup schedule',
},
manualBackup: {
id: 'servers.backups.item.manual-backup',
defaultMessage: 'Manual backup',
},
retry: {
id: 'servers.backups.item.retry',
defaultMessage: 'Retry',
},
})
</script>
<template>
<div
class="grid items-center gap-4 rounded-2xl bg-bg-raised p-4 shadow-md"
:class="preview ? 'grid-cols-2' : 'grid-cols-[auto_1fr_auto] md:grid-cols-[1fr_400px_1fr]'"
>
<div class="flex flex-row gap-4 items-center">
<div
class="flex size-12 shrink-0 items-center justify-center rounded-2xl border-solid border-[1px] border-surface-5 bg-surface-4 md:size-16"
>
<component :is="backupIcon" class="size-7 text-secondary md:size-10" />
</div>
<div class="flex min-w-0 flex-col gap-1.5">
<div class="flex flex-wrap items-center gap-2">
<span class="truncate font-semibold text-contrast max-w-[400px]">{{ backup.name }}</span>
<span
v-if="backup.automated"
class="rounded-full border-solid border-[1px] border-surface-5 bg-surface-4 px-2.5 py-1 text-sm text-secondary"
>
{{ formatMessage(messages.auto) }}
</span>
<span v-if="backup.locked" class="flex items-center gap-1 text-sm text-secondary">
<LockIcon class="size-4" />
</span>
</div>
<div class="flex items-center gap-1.5 text-sm text-secondary">
<template v-if="failedToCreate || failedToRestore">
<XIcon class="size-4 text-red" />
<span class="text-red">
{{
formatMessage(
failedToCreate ? messages.failedToCreateBackup : messages.failedToRestoreBackup,
)
}}
</span>
</template>
<template v-else>
<!-- TODO: Uncomment when API supports creator_id field -->
<!-- <template v-if="backup.creator_id && backup.creator_id !== 'auto'">
<Avatar ... class="size-6 rounded-full" />
<span>{{ creatorName }}</span>
</template>
<template v-else> -->
<span>
{{
formatMessage(backup.automated ? messages.backupSchedule : messages.manualBackup)
}}
</span>
<!-- </template> -->
</template>
</div>
</div>
</div>
<div
class="col-span-full row-start-2 flex flex-col gap-2 md:col-span-1 md:row-start-auto md:items-center"
>
<template v-if="creating || restoring">
<ProgressBar
:progress="(creating || restoring)!.progress"
:color="creating ? 'brand' : 'purple'"
:waiting="(creating || restoring)!.progress === 0"
:label="
formatMessage(
creating
? backupQueued
? messages.queuedForBackup
: messages.creatingBackup
: restoreQueued
? messages.queuedForRestore
: messages.restoringBackup,
)
"
:label-class="creating ? 'text-contrast' : 'text-purple'"
show-progress
full-width
/>
</template>
<template v-else>
<span class="w-full font-medium text-contrast md:text-center">
{{ dayjs(backup.created_at).format('MMMM Do YYYY, h:mm A') }}
</span>
<!-- TODO: Uncomment when API supports size field -->
<!-- <span class="text-secondary">{{ formatBytes(backup.size) }}</span> -->
</template>
</div>
<div v-if="!preview" class="flex shrink-0 items-center gap-2 md:justify-self-end">
<template v-if="failedToCreate">
<ButtonStyled>
<button @click="() => emit('retry')">
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(messages.retry) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="() => emit('delete', true)">
<TrashIcon class="size-5" />
{{ formatMessage(commonMessages.deleteLabel) }}
</button>
</ButtonStyled>
</template>
<template v-else-if="creating">
<ButtonStyled type="outlined">
<button class="!border-[1px] !border-surface-5" @click="() => emit('delete')">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu :options="overflowMenuOptions">
<MoreVerticalIcon class="size-5" />
<template #rename>
<EditIcon class="size-5" /> {{ formatMessage(messages.rename) }}
</template>
<template v-if="backup.locked" #lock>
<LockOpenIcon class="size-5" /> {{ formatMessage(messages.unlock) }}
</template>
<template v-else #lock>
<LockIcon class="size-5" /> {{ formatMessage(messages.lock) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
<template v-else>
<ButtonStyled color="brand" type="outlined">
<button
v-tooltip="props.disabled"
class="!border-[1px]"
:disabled="!!props.disabled"
@click="() => emit('restore')"
>
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(messages.restore) }}
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu :options="overflowMenuOptions">
<MoreVerticalIcon class="size-5" />
<template #download>
<DownloadIcon class="size-5" /> {{ formatMessage(commonMessages.downloadButton) }}
</template>
<template #rename>
<EditIcon class="size-5" /> {{ formatMessage(messages.rename) }}
</template>
<template v-if="backup.locked" #lock>
<LockOpenIcon class="size-5" /> {{ formatMessage(messages.unlock) }}
</template>
<template v-else #lock>
<LockIcon class="size-5" /> {{ formatMessage(messages.lock) }}
</template>
<template #delete>
<TrashIcon class="size-5" /> {{ formatMessage(commonMessages.deleteLabel) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</div>
<pre v-if="!preview && showDebugInfo" class="w-full rounded-xl bg-surface-4 p-2 text-xs">{{
backup
}}</pre>
</div>
</template>

View File

@@ -0,0 +1,163 @@
<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="renameMutation.isPending.value || nameExists" @click="renameBackup">
<template v-if="renameMutation.isPending.value">
<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 type { Archon } from '@modrinth/api-client'
import { IssuesIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref } from 'vue'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
import ButtonStyled from '../../base/ButtonStyled.vue'
import NewModal from '../../modal/NewModal.vue'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const ctx = injectModrinthServerContext()
const props = defineProps<{
backups?: Archon.Backups.v1.Backup[]
}>()
const backupsQueryKey = ['backups', 'list', ctx.serverId]
const renameMutation = useMutation({
mutationFn: ({ backupId, name }: { backupId: string; name: string }) =>
client.archon.backups_v0.rename(ctx.serverId, backupId, { name }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
})
const modal = ref<InstanceType<typeof NewModal>>()
const input = ref<HTMLInputElement>()
const backupName = ref('')
const originalName = ref('')
const currentBackup = ref<Archon.Backups.v1.Backup | null>(null)
const trimmedName = computed(() => backupName.value.trim())
const nameExists = computed(() => {
if (
!props.backups ||
trimmedName.value === originalName.value ||
renameMutation.isPending.value
) {
return false
}
return props.backups.some(
(backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
)
})
const backupNumber = computed(
() => (props.backups?.findIndex((b) => b.id === currentBackup.value?.id) ?? 0) + 1,
)
const focusInput = () => {
nextTick(() => {
setTimeout(() => {
input.value?.focus()
}, 100)
})
}
function show(backup: Archon.Backups.v1.Backup) {
currentBackup.value = backup
backupName.value = backup.name
originalName.value = backup.name
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
const renameBackup = () => {
if (!currentBackup.value) {
addNotification({
type: 'error',
title: 'Error renaming backup',
text: 'Current backup is null',
})
return
}
if (trimmedName.value === originalName.value) {
hide()
return
}
let newName = trimmedName.value
if (newName.length === 0) {
newName = `Backup #${backupNumber.value}`
}
renameMutation.mutate(
{ backupId: currentBackup.value.id, name: newName },
{
onSuccess: () => {
hide()
},
onError: (error) => {
const message = error instanceof Error ? error.message : String(error)
addNotification({ type: 'error', title: 'Error renaming backup', text: message })
hide()
},
},
)
}
defineExpose({
show,
hide,
})
</script>

View File

@@ -0,0 +1,108 @@
<template>
<NewModal ref="modal" header="Restore backup" fade="warning">
<div class="flex flex-col gap-6 max-w-[600px]">
<Admonition v-if="ctx.isServerRunning.value" type="critical" header="Server is running">
Stop the server before restoring a backup.
</Admonition>
<!-- TODO: Worlds: Replace "server" with "world" -->
<Admonition v-else type="warning" header="Restore warning">
This will overwrite all files in the server and replace them with the files from the backup.
</Admonition>
<div v-if="currentBackup" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Backup</span>
<BackupItem :backup="currentBackup" preview class="!bg-surface-2" />
</div>
</div>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled>
<button @click="modal?.hide()">
<XIcon />
Cancel
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button :disabled="isRestoring || ctx.isServerRunning.value" @click="restoreBackup">
<SpinnerIcon v-if="isRestoring" class="animate-spin" />
<RotateCounterClockwiseIcon v-else />
{{ isRestoring ? 'Restoring...' : 'Restore backup' }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { RotateCounterClockwiseIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { ref } from 'vue'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
import Admonition from '../../base/Admonition.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import NewModal from '../../modal/NewModal.vue'
import BackupItem from './BackupItem.vue'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const ctx = injectModrinthServerContext()
const backupsQueryKey = ['backups', 'list', ctx.serverId]
const restoreMutation = useMutation({
mutationFn: (backupId: string) => client.archon.backups_v0.restore(ctx.serverId, backupId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
})
const modal = ref<InstanceType<typeof NewModal>>()
const currentBackup = ref<Archon.Backups.v1.Backup | null>(null)
const isRestoring = ref(false)
function show(backup: Archon.Backups.v1.Backup) {
currentBackup.value = backup
modal.value?.show()
}
const restoreBackup = () => {
if (!currentBackup.value || isRestoring.value) {
if (!currentBackup.value) {
addNotification({
type: 'error',
title: 'Failed to restore backup',
text: 'Current backup is null',
})
}
return
}
isRestoring.value = true
restoreMutation.mutate(currentBackup.value.id, {
onSuccess: () => {
// Optimistically update backupsState to show restore in progress immediately
ctx.backupsState.set(currentBackup.value!.id, {
restore: { progress: 0, state: 'ongoing' },
})
modal.value?.hide()
},
onError: (error) => {
const message = error instanceof Error ? error.message : String(error)
addNotification({ type: 'error', title: 'Failed to restore backup', text: message })
},
onSettled: () => {
isRestoring.value = false
},
})
}
defineExpose({
show,
})
</script>

View File

@@ -0,0 +1,6 @@
export { default as BackupCreateModal } from './BackupCreateModal.vue'
export { default as BackupDeleteModal } from './BackupDeleteModal.vue'
export { default as BackupItem } from './BackupItem.vue'
export { default as BackupRenameModal } from './BackupRenameModal.vue'
export { default as BackupRestoreModal } from './BackupRestoreModal.vue'
export { default as BackupWarning } from './BackupWarning.vue'

View File

@@ -128,7 +128,8 @@
</template>
<script setup lang="ts">
import { type Archon, NuxtModrinthClient } from '@modrinth/api-client'
import type { Archon } from '@modrinth/api-client'
import { NuxtModrinthClient } from '@modrinth/api-client'
import {
ChevronRightIcon,
LoaderCircleIcon,
@@ -152,7 +153,23 @@ import MedalBackgroundImage from './MedalBackgroundImage.vue'
dayjs.extend(dayjsDuration)
const props = defineProps<Partial<Archon.Servers.v0.Server>>()
type MedalServerListingProps = {
server_id: string
name: string
status: Archon.Servers.v0.Status
suspension_reason?: Archon.Servers.v0.SuspensionReason | null
game?: Archon.Servers.v0.Game
mc_version?: string | null
loader?: Archon.Servers.v0.Loader | null
loader_version?: string | null
net?: Archon.Servers.v0.Net
upstream?: Archon.Servers.v0.Upstream | null
flows?: Archon.Servers.v0.Flows
medal_expires?: string
}
const props = defineProps<MedalServerListingProps>()
const emit = defineEmits<{ (e: 'upgrade'): void }>()
const client = injectModrinthClient()