You've already forked AstralRinth
forked from didirus/AstralRinth
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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"fix": "eslint . --fix && prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"mitt": "^3.0.1",
|
||||
"ofetch": "^1.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ClientConfig } from '../types/client'
|
||||
import type { RequestContext, RequestOptions } from '../types/request'
|
||||
import type { AbstractFeature } from './abstract-feature'
|
||||
import type { AbstractModule } from './abstract-module'
|
||||
import type { AbstractWebSocketClient } from './abstract-websocket'
|
||||
import { ModrinthApiError, ModrinthServerError } from './errors'
|
||||
|
||||
/**
|
||||
@@ -24,7 +25,7 @@ export abstract class AbstractModrinthClient {
|
||||
private _moduleNamespaces: Map<string, Record<string, AbstractModule>> = new Map()
|
||||
|
||||
public readonly labrinth!: InferredClientModules['labrinth']
|
||||
public readonly archon!: InferredClientModules['archon']
|
||||
public readonly archon!: InferredClientModules['archon'] & { sockets: AbstractWebSocketClient }
|
||||
public readonly kyros!: InferredClientModules['kyros']
|
||||
public readonly iso3166!: InferredClientModules['iso3166']
|
||||
|
||||
|
||||
106
packages/api-client/src/core/abstract-websocket.ts
Normal file
106
packages/api-client/src/core/abstract-websocket.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type mitt from 'mitt'
|
||||
|
||||
import type { Archon } from '../modules/archon/types'
|
||||
|
||||
export type WebSocketEventHandler<
|
||||
E extends Archon.Websocket.v0.WSEvent = Archon.Websocket.v0.WSEvent,
|
||||
> = (event: E) => void
|
||||
|
||||
export interface WebSocketConnection {
|
||||
serverId: string
|
||||
socket: WebSocket
|
||||
reconnectAttempts: number
|
||||
reconnectTimer?: ReturnType<typeof setTimeout>
|
||||
isReconnecting: boolean
|
||||
}
|
||||
|
||||
export interface WebSocketStatus {
|
||||
connected: boolean
|
||||
reconnecting: boolean
|
||||
reconnectAttempts: number
|
||||
}
|
||||
|
||||
type WSEventMap = {
|
||||
[K in Archon.Websocket.v0.WSEvent as `${string}:${K['event']}`]: K
|
||||
}
|
||||
|
||||
export abstract class AbstractWebSocketClient {
|
||||
protected connections = new Map<string, WebSocketConnection>()
|
||||
protected abstract emitter: ReturnType<typeof mitt<WSEventMap>>
|
||||
|
||||
protected readonly MAX_RECONNECT_ATTEMPTS = 10
|
||||
protected readonly RECONNECT_BASE_DELAY = 1000
|
||||
protected readonly RECONNECT_MAX_DELAY = 30000
|
||||
|
||||
constructor(
|
||||
protected client: {
|
||||
archon: {
|
||||
servers_v0: {
|
||||
getWebSocketAuth: (serverId: string) => Promise<Archon.Websocket.v0.WSAuth>
|
||||
}
|
||||
}
|
||||
},
|
||||
) {}
|
||||
|
||||
abstract connect(serverId: string, auth: Archon.Websocket.v0.WSAuth): Promise<void>
|
||||
|
||||
abstract disconnect(serverId: string): void
|
||||
|
||||
abstract disconnectAll(): void
|
||||
|
||||
abstract send(serverId: string, message: Archon.Websocket.v0.WSOutgoingMessage): void
|
||||
|
||||
async safeConnect(serverId: string, options?: { force?: boolean }): Promise<void> {
|
||||
const status = this.getStatus(serverId)
|
||||
|
||||
if (status?.connected && !options?.force) {
|
||||
return
|
||||
}
|
||||
|
||||
if (status && !status.connected && !options?.force) {
|
||||
return
|
||||
}
|
||||
|
||||
if (options?.force && status) {
|
||||
this.disconnect(serverId)
|
||||
}
|
||||
|
||||
const auth = await this.client.archon.servers_v0.getWebSocketAuth(serverId)
|
||||
await this.connect(serverId, auth)
|
||||
}
|
||||
|
||||
on<E extends Archon.Websocket.v0.WSEventType>(
|
||||
serverId: string,
|
||||
eventType: E,
|
||||
handler: WebSocketEventHandler<Extract<Archon.Websocket.v0.WSEvent, { event: E }>>,
|
||||
): () => void {
|
||||
const eventKey = `${serverId}:${eventType}` as keyof WSEventMap
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.emitter.on(eventKey, handler as any)
|
||||
|
||||
return () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.emitter.off(eventKey, handler as any)
|
||||
}
|
||||
}
|
||||
|
||||
getStatus(serverId: string): WebSocketStatus | null {
|
||||
const connection = this.connections.get(serverId)
|
||||
if (!connection) return null
|
||||
|
||||
return {
|
||||
connected: connection.socket.readyState === WebSocket.OPEN,
|
||||
reconnecting: connection.isReconnecting,
|
||||
reconnectAttempts: connection.reconnectAttempts,
|
||||
}
|
||||
}
|
||||
|
||||
protected getReconnectDelay(attempt: number): number {
|
||||
const delay = Math.min(
|
||||
this.RECONNECT_BASE_DELAY * Math.pow(2, attempt),
|
||||
this.RECONNECT_MAX_DELAY,
|
||||
)
|
||||
return delay + Math.random() * 1000
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
export { AbstractModrinthClient } from './core/abstract-client'
|
||||
export { AbstractFeature, type FeatureConfig } from './core/abstract-feature'
|
||||
export {
|
||||
AbstractWebSocketClient,
|
||||
type WebSocketConnection,
|
||||
type WebSocketEventHandler,
|
||||
type WebSocketStatus,
|
||||
} from './core/abstract-websocket'
|
||||
export { ModrinthApiError, ModrinthServerError } from './core/errors'
|
||||
export { type AuthConfig, AuthFeature } from './features/auth'
|
||||
export {
|
||||
|
||||
95
packages/api-client/src/modules/archon/backups/v0.ts
Normal file
95
packages/api-client/src/modules/archon/backups/v0.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { Archon } from '../types'
|
||||
|
||||
export class ArchonBackupsV0Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'archon_backups_v0'
|
||||
}
|
||||
|
||||
/** GET /modrinth/v0/servers/:server_id/backups */
|
||||
public async list(serverId: string): Promise<Archon.Backups.v1.Backup[]> {
|
||||
return this.client.request<Archon.Backups.v1.Backup[]>(`/servers/${serverId}/backups`, {
|
||||
api: 'archon',
|
||||
version: 'modrinth/v0',
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
/** GET /modrinth/v0/servers/:server_id/backups/:backup_id */
|
||||
public async get(serverId: string, backupId: string): Promise<Archon.Backups.v1.Backup> {
|
||||
return this.client.request<Archon.Backups.v1.Backup>(
|
||||
`/servers/${serverId}/backups/${backupId}`,
|
||||
{ api: 'archon', version: 'modrinth/v0', method: 'GET' },
|
||||
)
|
||||
}
|
||||
|
||||
/** POST /modrinth/v0/servers/:server_id/backups */
|
||||
public async create(
|
||||
serverId: string,
|
||||
request: Archon.Backups.v1.BackupRequest,
|
||||
): Promise<Archon.Backups.v1.PostBackupResponse> {
|
||||
return this.client.request<Archon.Backups.v1.PostBackupResponse>(
|
||||
`/servers/${serverId}/backups`,
|
||||
{ api: 'archon', version: 'modrinth/v0', method: 'POST', body: request },
|
||||
)
|
||||
}
|
||||
|
||||
/** POST /modrinth/v0/servers/:server_id/backups/:backup_id/restore */
|
||||
public async restore(serverId: string, backupId: string): Promise<void> {
|
||||
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}/restore`, {
|
||||
api: 'archon',
|
||||
version: 'modrinth/v0',
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/** DELETE /modrinth/v0/servers/:server_id/backups/:backup_id */
|
||||
public async delete(serverId: string, backupId: string): Promise<void> {
|
||||
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}`, {
|
||||
api: 'archon',
|
||||
version: 'modrinth/v0',
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /modrinth/v0/servers/:server_id/backups/:backup_id/lock */
|
||||
public async lock(serverId: string, backupId: string): Promise<void> {
|
||||
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}/lock`, {
|
||||
api: 'archon',
|
||||
version: 'modrinth/v0',
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /modrinth/v0/servers/:server_id/backups/:backup_id/unlock */
|
||||
public async unlock(serverId: string, backupId: string): Promise<void> {
|
||||
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}/unlock`, {
|
||||
api: 'archon',
|
||||
version: 'modrinth/v0',
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /modrinth/v0/servers/:server_id/backups/:backup_id/retry */
|
||||
public async retry(serverId: string, backupId: string): Promise<void> {
|
||||
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}/retry`, {
|
||||
api: 'archon',
|
||||
version: 'modrinth/v0',
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/** PATCH /modrinth/v0/servers/:server_id/backups/:backup_id */
|
||||
public async rename(
|
||||
serverId: string,
|
||||
backupId: string,
|
||||
request: Archon.Backups.v1.PatchBackup,
|
||||
): Promise<void> {
|
||||
await this.client.request<void>(`/servers/${serverId}/backups/${backupId}`, {
|
||||
api: 'archon',
|
||||
version: 'modrinth/v0',
|
||||
method: 'PATCH',
|
||||
body: request,
|
||||
})
|
||||
}
|
||||
}
|
||||
132
packages/api-client/src/modules/archon/backups/v1.ts
Normal file
132
packages/api-client/src/modules/archon/backups/v1.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { Archon } from '../types'
|
||||
|
||||
/**
|
||||
* Default world ID - Uuid::nil() which the backend treats as "first/active world"
|
||||
* See: apps/archon/src/routes/v1/servers/worlds/mod.rs - world_id_nullish()
|
||||
* TODO:
|
||||
* - Make sure world ID is being passed before we ship worlds.
|
||||
* - The schema will change when Backups v4 (routes stay as v1) so remember to do that.
|
||||
*/
|
||||
const DEFAULT_WORLD_ID: string = '00000000-0000-0000-0000-000000000000' as const
|
||||
|
||||
export class ArchonBackupsV1Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'archon_backups_v1'
|
||||
}
|
||||
|
||||
/** GET /v1/:server_id/worlds/:world_id/backups */
|
||||
public async list(
|
||||
serverId: string,
|
||||
worldId: string = DEFAULT_WORLD_ID,
|
||||
): Promise<Archon.Backups.v1.Backup[]> {
|
||||
return this.client.request<Archon.Backups.v1.Backup[]>(
|
||||
`/${serverId}/worlds/${worldId}/backups`,
|
||||
{ api: 'archon', version: 1, method: 'GET' },
|
||||
)
|
||||
}
|
||||
|
||||
/** GET /v1/:server_id/worlds/:world_id/backups/:backup_id */
|
||||
public async get(
|
||||
serverId: string,
|
||||
backupId: string,
|
||||
worldId: string = DEFAULT_WORLD_ID,
|
||||
): Promise<Archon.Backups.v1.Backup> {
|
||||
return this.client.request<Archon.Backups.v1.Backup>(
|
||||
`/${serverId}/worlds/${worldId}/backups/${backupId}`,
|
||||
{ api: 'archon', version: 1, method: 'GET' },
|
||||
)
|
||||
}
|
||||
|
||||
/** POST /v1/:server_id/worlds/:world_id/backups */
|
||||
public async create(
|
||||
serverId: string,
|
||||
request: Archon.Backups.v1.BackupRequest,
|
||||
worldId: string = DEFAULT_WORLD_ID,
|
||||
): Promise<Archon.Backups.v1.PostBackupResponse> {
|
||||
return this.client.request<Archon.Backups.v1.PostBackupResponse>(
|
||||
`/${serverId}/worlds/${worldId}/backups`,
|
||||
{ api: 'archon', version: 1, method: 'POST', body: request },
|
||||
)
|
||||
}
|
||||
|
||||
/** POST /v1/:server_id/worlds/:world_id/backups/:backup_id/restore */
|
||||
public async restore(
|
||||
serverId: string,
|
||||
backupId: string,
|
||||
worldId: string = DEFAULT_WORLD_ID,
|
||||
): Promise<void> {
|
||||
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}/restore`, {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/** DELETE /v1/:server_id/worlds/:world_id/backups/:backup_id */
|
||||
public async delete(
|
||||
serverId: string,
|
||||
backupId: string,
|
||||
worldId: string = DEFAULT_WORLD_ID,
|
||||
): Promise<void> {
|
||||
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}`, {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /v1/:server_id/worlds/:world_id/backups/:backup_id/lock */
|
||||
public async lock(
|
||||
serverId: string,
|
||||
backupId: string,
|
||||
worldId: string = DEFAULT_WORLD_ID,
|
||||
): Promise<void> {
|
||||
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}/lock`, {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /v1/:server_id/worlds/:world_id/backups/:backup_id/unlock */
|
||||
public async unlock(
|
||||
serverId: string,
|
||||
backupId: string,
|
||||
worldId: string = DEFAULT_WORLD_ID,
|
||||
): Promise<void> {
|
||||
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}/unlock`, {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /v1/:server_id/worlds/:world_id/backups/:backup_id/retry */
|
||||
public async retry(
|
||||
serverId: string,
|
||||
backupId: string,
|
||||
worldId: string = DEFAULT_WORLD_ID,
|
||||
): Promise<void> {
|
||||
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}/retry`, {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/** PATCH /v1/:server_id/worlds/:world_id/backups/:backup_id */
|
||||
public async rename(
|
||||
serverId: string,
|
||||
backupId: string,
|
||||
request: Archon.Backups.v1.PatchBackup,
|
||||
worldId: string = DEFAULT_WORLD_ID,
|
||||
): Promise<void> {
|
||||
await this.client.request<void>(`/${serverId}/worlds/${worldId}/backups/${backupId}`, {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'PATCH',
|
||||
body: request,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './servers/types'
|
||||
export * from './backups/v0'
|
||||
export * from './backups/v1'
|
||||
export * from './servers/v0'
|
||||
export * from './servers/v1'
|
||||
export * from './types'
|
||||
|
||||
@@ -6,6 +6,18 @@ export class ArchonServersV0Module extends AbstractModule {
|
||||
return 'archon_servers_v0'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific server by ID
|
||||
* GET /modrinth/v0/servers/:id
|
||||
*/
|
||||
public async get(serverId: string): Promise<Archon.Servers.v0.Server> {
|
||||
return this.client.request<Archon.Servers.v0.Server>(`/servers/${serverId}`, {
|
||||
api: 'archon',
|
||||
method: 'GET',
|
||||
version: 'modrinth/v0',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of servers for the authenticated user
|
||||
* GET /modrinth/v0/servers
|
||||
@@ -54,4 +66,16 @@ export class ArchonServersV0Module extends AbstractModule {
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WebSocket authentication credentials for a server
|
||||
* GET /modrinth/v0/servers/:id/ws
|
||||
*/
|
||||
public async getWebSocketAuth(serverId: string): Promise<Archon.Websocket.v0.WSAuth> {
|
||||
return this.client.request<Archon.Websocket.v0.WSAuth>(`/servers/${serverId}/ws`, {
|
||||
api: 'archon',
|
||||
version: 'modrinth/v0',
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,4 +125,187 @@ export namespace Archon {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Backups {
|
||||
export namespace v1 {
|
||||
export type BackupState = 'ongoing' | 'done' | 'failed' | 'cancelled' | 'unchanged'
|
||||
export type BackupTask = 'file' | 'create' | 'restore'
|
||||
|
||||
export type BackupTaskProgress = {
|
||||
progress: number // 0.0 to 1.0
|
||||
state: BackupState
|
||||
}
|
||||
|
||||
export type Backup = {
|
||||
id: string
|
||||
name: string
|
||||
created_at: string
|
||||
locked: boolean
|
||||
automated: boolean
|
||||
interrupted: boolean
|
||||
ongoing: boolean
|
||||
task?: {
|
||||
file?: BackupTaskProgress
|
||||
create?: BackupTaskProgress
|
||||
restore?: BackupTaskProgress
|
||||
}
|
||||
// TODO: Uncomment when API supports these fields
|
||||
// size?: number // bytes
|
||||
// creator_id?: string // user ID, or 'auto' for automated backups
|
||||
}
|
||||
|
||||
export type BackupRequest = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export type PatchBackup = {
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type PostBackupResponse = {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Websocket {
|
||||
export namespace v0 {
|
||||
export type WSAuth = {
|
||||
url: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export type BackupState = 'ongoing' | 'done' | 'failed' | 'cancelled' | 'unchanged'
|
||||
export type BackupTask = 'file' | 'create' | 'restore'
|
||||
|
||||
export type WSBackupProgressEvent = {
|
||||
event: 'backup-progress'
|
||||
id: string
|
||||
task: BackupTask
|
||||
state: BackupState
|
||||
progress: number
|
||||
}
|
||||
|
||||
export type WSLogEvent = {
|
||||
event: 'log'
|
||||
stream: 'stdout' | 'stderr'
|
||||
message: string
|
||||
}
|
||||
|
||||
export type WSStatsEvent = {
|
||||
event: 'stats'
|
||||
cpu_percent: number
|
||||
ram_usage_bytes: number
|
||||
ram_total_bytes: number
|
||||
storage_usage_bytes: number
|
||||
storage_total_bytes: number
|
||||
net_tx_bytes: number
|
||||
net_rx_bytes: number
|
||||
}
|
||||
|
||||
export type PowerState = 'running' | 'stopped' | 'starting' | 'stopping' | 'crashed'
|
||||
|
||||
export type WSPowerStateEvent = {
|
||||
event: 'power-state'
|
||||
state: PowerState
|
||||
oom_killed?: boolean
|
||||
exit_code?: number
|
||||
}
|
||||
|
||||
export type WSAuthExpiringEvent = {
|
||||
event: 'auth-expiring'
|
||||
}
|
||||
|
||||
export type WSAuthIncorrectEvent = {
|
||||
event: 'auth-incorrect'
|
||||
}
|
||||
|
||||
export type WSAuthOkEvent = {
|
||||
event: 'auth-ok'
|
||||
}
|
||||
|
||||
export type WSInstallationResultEvent =
|
||||
| WSInstallationResultOkEvent
|
||||
| WSInstallationResultErrEvent
|
||||
|
||||
export type WSInstallationResultOkEvent = {
|
||||
event: 'installation-result'
|
||||
result: 'ok'
|
||||
}
|
||||
|
||||
export type WSInstallationResultErrEvent = {
|
||||
event: 'installation-result'
|
||||
result: 'err'
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export type WSUptimeEvent = {
|
||||
event: 'uptime'
|
||||
uptime: number
|
||||
}
|
||||
|
||||
export type WSNewModEvent = {
|
||||
event: 'new-mod'
|
||||
project_id: string
|
||||
version_id: string
|
||||
}
|
||||
|
||||
export type FilesystemOpKind = 'unarchive'
|
||||
|
||||
export type FilesystemOpState =
|
||||
| 'queued'
|
||||
| 'ongoing'
|
||||
| 'done'
|
||||
| 'cancelled'
|
||||
| 'failure-corrupted'
|
||||
| 'failure-invalid-path'
|
||||
|
||||
export type FilesystemOperation = {
|
||||
op: FilesystemOpKind
|
||||
id: string
|
||||
progress: number
|
||||
bytes_processed: number
|
||||
files_processed: number
|
||||
state: FilesystemOpState
|
||||
mime: string
|
||||
current_file?: string
|
||||
invalid_path?: string
|
||||
src: string
|
||||
started: string
|
||||
}
|
||||
|
||||
export type WSFilesystemOpsEvent = {
|
||||
event: 'filesystem-ops'
|
||||
all: FilesystemOperation[]
|
||||
}
|
||||
|
||||
// Outgoing messages (client -> server)
|
||||
export type WSOutgoingMessage = WSAuthMessage | WSCommandMessage
|
||||
|
||||
export type WSAuthMessage = {
|
||||
event: 'auth'
|
||||
jwt: string
|
||||
}
|
||||
|
||||
export type WSCommandMessage = {
|
||||
event: 'command'
|
||||
cmd: string
|
||||
}
|
||||
|
||||
export type WSEvent =
|
||||
| WSBackupProgressEvent
|
||||
| WSLogEvent
|
||||
| WSStatsEvent
|
||||
| WSPowerStateEvent
|
||||
| WSAuthExpiringEvent
|
||||
| WSAuthIncorrectEvent
|
||||
| WSAuthOkEvent
|
||||
| WSInstallationResultEvent
|
||||
| WSUptimeEvent
|
||||
| WSNewModEvent
|
||||
| WSFilesystemOpsEvent
|
||||
|
||||
export type WSEventType = WSEvent['event']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { AbstractModrinthClient } from '../core/abstract-client'
|
||||
import type { AbstractModule } from '../core/abstract-module'
|
||||
import { ArchonBackupsV0Module } from './archon/backups/v0'
|
||||
import { ArchonBackupsV1Module } from './archon/backups/v1'
|
||||
import { ArchonServersV0Module } from './archon/servers/v0'
|
||||
import { ArchonServersV1Module } from './archon/servers/v1'
|
||||
import { ISO3166Module } from './iso3166'
|
||||
@@ -21,6 +23,8 @@ type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
|
||||
* TODO: Better way? Probably not
|
||||
*/
|
||||
export const MODULE_REGISTRY = {
|
||||
archon_backups_v0: ArchonBackupsV0Module,
|
||||
archon_backups_v1: ArchonBackupsV1Module,
|
||||
archon_servers_v0: ArchonServersV0Module,
|
||||
archon_servers_v1: ArchonServersV1Module,
|
||||
iso3166_data: ISO3166Module,
|
||||
|
||||
@@ -2,7 +2,9 @@ import { $fetch, FetchError } from 'ofetch'
|
||||
|
||||
import { AbstractModrinthClient } from '../core/abstract-client'
|
||||
import type { ModrinthApiError } from '../core/errors'
|
||||
import type { ClientConfig } from '../types/client'
|
||||
import type { RequestOptions } from '../types/request'
|
||||
import { GenericWebSocketClient } from './websocket-generic'
|
||||
|
||||
/**
|
||||
* Generic platform client using ofetch
|
||||
@@ -23,6 +25,17 @@ import type { RequestOptions } from '../types/request'
|
||||
* ```
|
||||
*/
|
||||
export class GenericModrinthClient extends AbstractModrinthClient {
|
||||
constructor(config: ClientConfig) {
|
||||
super(config)
|
||||
|
||||
Object.defineProperty(this.archon, 'sockets', {
|
||||
value: new GenericWebSocketClient(this),
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
}
|
||||
|
||||
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
|
||||
try {
|
||||
const response = await $fetch<T>(url, {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ModrinthApiError } from '../core/errors'
|
||||
import type { CircuitBreakerState, CircuitBreakerStorage } from '../features/circuit-breaker'
|
||||
import type { ClientConfig } from '../types/client'
|
||||
import type { RequestOptions } from '../types/request'
|
||||
import { GenericWebSocketClient } from './websocket-generic'
|
||||
|
||||
/**
|
||||
* Circuit breaker storage using Nuxt's useState
|
||||
@@ -72,6 +73,17 @@ export interface NuxtClientConfig extends ClientConfig {
|
||||
export class NuxtModrinthClient extends AbstractModrinthClient {
|
||||
protected declare config: NuxtClientConfig
|
||||
|
||||
constructor(config: NuxtClientConfig) {
|
||||
super(config)
|
||||
|
||||
Object.defineProperty(this.archon, 'sockets', {
|
||||
value: new GenericWebSocketClient(this),
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
}
|
||||
|
||||
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
|
||||
try {
|
||||
// @ts-expect-error - $fetch is provided by Nuxt runtime
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AbstractModrinthClient } from '../core/abstract-client'
|
||||
import type { ModrinthApiError } from '../core/errors'
|
||||
import type { ClientConfig } from '../types/client'
|
||||
import type { RequestOptions } from '../types/request'
|
||||
import { GenericWebSocketClient } from './websocket-generic'
|
||||
|
||||
/**
|
||||
* Tauri-specific configuration
|
||||
@@ -38,6 +39,17 @@ interface HttpError extends Error {
|
||||
export class TauriModrinthClient extends AbstractModrinthClient {
|
||||
protected declare config: TauriClientConfig
|
||||
|
||||
constructor(config: TauriClientConfig) {
|
||||
super(config)
|
||||
|
||||
Object.defineProperty(this.archon, 'sockets', {
|
||||
value: new GenericWebSocketClient(this),
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
}
|
||||
|
||||
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
|
||||
try {
|
||||
// Dynamically import Tauri HTTP plugin
|
||||
|
||||
147
packages/api-client/src/platform/websocket-generic.ts
Normal file
147
packages/api-client/src/platform/websocket-generic.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import mitt from 'mitt'
|
||||
|
||||
import { AbstractWebSocketClient, type WebSocketConnection } from '../core/abstract-websocket'
|
||||
import type { Archon } from '../modules/archon/types'
|
||||
|
||||
type WSEventMap = {
|
||||
[K in Archon.Websocket.v0.WSEvent as `${string}:${K['event']}`]: K
|
||||
}
|
||||
|
||||
export class GenericWebSocketClient extends AbstractWebSocketClient {
|
||||
protected emitter = mitt<WSEventMap>()
|
||||
|
||||
async connect(serverId: string, auth: Archon.Websocket.v0.WSAuth): Promise<void> {
|
||||
if (this.connections.has(serverId)) {
|
||||
this.disconnect(serverId)
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const ws = new WebSocket(`wss://${auth.url}`)
|
||||
|
||||
const connection: WebSocketConnection = {
|
||||
serverId,
|
||||
socket: ws,
|
||||
reconnectAttempts: 0,
|
||||
reconnectTimer: undefined,
|
||||
isReconnecting: false,
|
||||
}
|
||||
|
||||
this.connections.set(serverId, connection)
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ event: 'auth', jwt: auth.token }))
|
||||
|
||||
connection.reconnectAttempts = 0
|
||||
connection.isReconnecting = false
|
||||
|
||||
resolve()
|
||||
}
|
||||
|
||||
ws.onmessage = (messageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(messageEvent.data) as Archon.Websocket.v0.WSEvent
|
||||
|
||||
const eventKey = `${serverId}:${data.event}` as keyof WSEventMap
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.emitter.emit(eventKey, data as any)
|
||||
|
||||
if (data.event === 'auth-expiring' || data.event === 'auth-incorrect') {
|
||||
this.handleAuthExpiring(serverId).catch(console.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[WebSocket] Failed to parse message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (event.code !== 1000) {
|
||||
this.scheduleReconnect(serverId, auth)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error(`[WebSocket] Error for server ${serverId}:`, error)
|
||||
reject(new Error(`WebSocket connection failed for server ${serverId}`))
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
disconnect(serverId: string): void {
|
||||
const connection = this.connections.get(serverId)
|
||||
if (!connection) return
|
||||
|
||||
if (connection.reconnectTimer) {
|
||||
clearTimeout(connection.reconnectTimer)
|
||||
connection.reconnectTimer = undefined
|
||||
}
|
||||
|
||||
if (
|
||||
connection.socket.readyState === WebSocket.OPEN ||
|
||||
connection.socket.readyState === WebSocket.CONNECTING
|
||||
) {
|
||||
connection.socket.close(1000, 'Client disconnecting')
|
||||
}
|
||||
|
||||
this.emitter.all.forEach((_handlers, type) => {
|
||||
if (type.toString().startsWith(`${serverId}:`)) {
|
||||
this.emitter.all.delete(type)
|
||||
}
|
||||
})
|
||||
|
||||
this.connections.delete(serverId)
|
||||
}
|
||||
|
||||
disconnectAll(): void {
|
||||
for (const serverId of this.connections.keys()) {
|
||||
this.disconnect(serverId)
|
||||
}
|
||||
}
|
||||
|
||||
send(serverId: string, message: Archon.Websocket.v0.WSOutgoingMessage): void {
|
||||
const connection = this.connections.get(serverId)
|
||||
if (!connection || connection.socket.readyState !== WebSocket.OPEN) {
|
||||
console.warn(`Cannot send message: WebSocket not connected for server ${serverId}`)
|
||||
return
|
||||
}
|
||||
connection.socket.send(JSON.stringify(message))
|
||||
}
|
||||
|
||||
private scheduleReconnect(serverId: string, auth: Archon.Websocket.v0.WSAuth): void {
|
||||
const connection = this.connections.get(serverId)
|
||||
if (!connection) return
|
||||
|
||||
if (connection.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
|
||||
this.disconnect(serverId)
|
||||
return
|
||||
}
|
||||
|
||||
connection.isReconnecting = true
|
||||
connection.reconnectAttempts++
|
||||
|
||||
const delay = this.getReconnectDelay(connection.reconnectAttempts)
|
||||
|
||||
connection.reconnectTimer = setTimeout(() => {
|
||||
this.connect(serverId, auth).catch((error) => {
|
||||
console.error(`[WebSocket] Reconnection failed for server ${serverId}:`, error)
|
||||
})
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private async handleAuthExpiring(serverId: string): Promise<void> {
|
||||
try {
|
||||
const newAuth = await this.client.archon.servers_v0.getWebSocketAuth(serverId)
|
||||
|
||||
const connection = this.connections.get(serverId)
|
||||
if (connection && connection.socket.readyState === WebSocket.OPEN) {
|
||||
connection.socket.send(JSON.stringify({ event: 'auth', jwt: newAuth.token }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[WebSocket] Failed to refresh auth for server ${serverId}:`, error)
|
||||
this.disconnect(serverId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export type {
|
||||
CircuitBreakerStorage,
|
||||
} from '../features/circuit-breaker'
|
||||
export type { BackoffStrategy, RetryConfig } from '../features/retry'
|
||||
export type { Archon } from '../modules/archon/types'
|
||||
export type { ClientConfig, RequestHooks } from './client'
|
||||
export type { ApiErrorData, ModrinthErrorResponse } from './errors'
|
||||
export { isModrinthErrorResponse } from './errors'
|
||||
|
||||
@@ -203,6 +203,7 @@ import _UploadIcon from './icons/upload.svg?component'
|
||||
import _UserIcon from './icons/user.svg?component'
|
||||
import _UserCogIcon from './icons/user-cog.svg?component'
|
||||
import _UserPlusIcon from './icons/user-plus.svg?component'
|
||||
import _UserRoundIcon from './icons/user-round.svg?component'
|
||||
import _UserSearchIcon from './icons/user-search.svg?component'
|
||||
import _UserXIcon from './icons/user-x.svg?component'
|
||||
import _UsersIcon from './icons/users.svg?component'
|
||||
@@ -417,6 +418,7 @@ export const UpdatedIcon = _UpdatedIcon
|
||||
export const UploadIcon = _UploadIcon
|
||||
export const UserCogIcon = _UserCogIcon
|
||||
export const UserPlusIcon = _UserPlusIcon
|
||||
export const UserRoundIcon = _UserRoundIcon
|
||||
export const UserSearchIcon = _UserSearchIcon
|
||||
export const UserXIcon = _UserXIcon
|
||||
export const UserIcon = _UserIcon
|
||||
|
||||
6
packages/assets/icons/user-round.svg
Normal file
6
packages/assets/icons/user-round.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="lucide lucide-user-round-icon lucide-user-round">
|
||||
<circle cx="12" cy="8" r="5" />
|
||||
<path d="M20 21a8 8 0 0 0-16 0" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 316 B |
@@ -43,6 +43,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const accentedButton = computed(() =>
|
||||
@@ -69,6 +73,7 @@ const classes = computed(() => {
|
||||
'btn-hover-filled-only': props.hoverFilledOnly,
|
||||
'btn-outline': props.outline,
|
||||
'color-accent-contrast': accentedButton,
|
||||
disabled: props.disabled,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -78,10 +83,14 @@ const classes = computed(() => {
|
||||
v-if="link && link.startsWith('/')"
|
||||
class="btn"
|
||||
:class="classes"
|
||||
:to="link"
|
||||
:to="disabled ? '' : link"
|
||||
:target="external ? '_blank' : '_self'"
|
||||
@click="
|
||||
(event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (action) {
|
||||
action(event)
|
||||
}
|
||||
@@ -96,10 +105,14 @@ const classes = computed(() => {
|
||||
v-else-if="link"
|
||||
class="btn"
|
||||
:class="classes"
|
||||
:href="link"
|
||||
:href="disabled ? undefined : link"
|
||||
:target="external ? '_blank' : '_self'"
|
||||
@click="
|
||||
(event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (action) {
|
||||
action(event)
|
||||
}
|
||||
@@ -110,7 +123,7 @@ const classes = computed(() => {
|
||||
<ExternalIcon v-if="external && !iconOnly" class="external-icon" />
|
||||
<UnknownIcon v-if="!$slots.default" />
|
||||
</a>
|
||||
<button v-else class="btn" :class="classes" @click="action">
|
||||
<button v-else class="btn" :class="classes" :disabled="disabled" @click="action">
|
||||
<slot />
|
||||
<UnknownIcon v-if="!$slots.default" />
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import SpinnerIcon from '@modrinth/assets/icons/spinner.svg'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -7,11 +8,21 @@ const props = withDefaults(
|
||||
max?: number
|
||||
color?: 'brand' | 'green' | 'red' | 'orange' | 'blue' | 'purple' | 'gray'
|
||||
waiting?: boolean
|
||||
fullWidth?: boolean
|
||||
striped?: boolean
|
||||
gradientBorder?: boolean
|
||||
label?: string
|
||||
labelClass?: string
|
||||
showProgress?: boolean
|
||||
}>(),
|
||||
{
|
||||
max: 1,
|
||||
color: 'brand',
|
||||
waiting: false,
|
||||
fullWidth: false,
|
||||
striped: false,
|
||||
gradientBorder: true,
|
||||
showProgress: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -49,15 +60,28 @@ const colors = {
|
||||
const percent = computed(() => props.progress / props.max)
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[15rem] h-1 rounded-full overflow-hidden"
|
||||
:class="colors[props.color].bg"
|
||||
>
|
||||
<div
|
||||
class="rounded-full progress-bar"
|
||||
:class="[colors[props.color].fg, { 'progress-bar--waiting': waiting }]"
|
||||
:style="!waiting ? { width: `${percent * 100}%` } : {}"
|
||||
></div>
|
||||
<div class="flex w-full flex-col gap-2" :class="fullWidth ? '' : 'max-w-[15rem]'">
|
||||
<div v-if="label || showProgress" class="flex items-center justify-between">
|
||||
<span v-if="label" :class="labelClass">{{ label }}</span>
|
||||
<div v-if="showProgress" class="flex items-center gap-1 text-sm text-secondary">
|
||||
<span>{{ Math.round(percent * 100) }}%</span>
|
||||
<slot name="progress-icon">
|
||||
<SpinnerIcon class="size-5 animate-spin" />
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex h-2 w-full overflow-hidden rounded-full" :class="[colors[props.color].bg]">
|
||||
<div
|
||||
class="rounded-full progress-bar"
|
||||
:class="[
|
||||
colors[props.color].fg,
|
||||
{ 'progress-bar--waiting': waiting },
|
||||
{ 'progress-bar--gradient-border': gradientBorder },
|
||||
striped ? `progress-bar--striped--${color}` : '',
|
||||
]"
|
||||
:style="!waiting ? { width: `${percent * 100}%` } : {}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
@@ -83,4 +107,76 @@ const percent = computed(() => props.progress / props.max)
|
||||
width: 20%;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar--gradient-border {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.3), transparent);
|
||||
border-radius: inherit;
|
||||
mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
mask-composite: xor;
|
||||
padding: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
%progress-bar--striped-common {
|
||||
background-attachment: scroll;
|
||||
background-position: 0 0;
|
||||
background-size: 9.38px 9.38px;
|
||||
}
|
||||
|
||||
@mixin striped-background($color-variable) {
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
$color-variable 11.54%,
|
||||
transparent 11.54%,
|
||||
transparent 50%,
|
||||
$color-variable 50%,
|
||||
$color-variable 61.54%,
|
||||
transparent 61.54%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.progress-bar--striped--brand {
|
||||
@include striped-background(var(--color-brand));
|
||||
@extend %progress-bar--striped-common;
|
||||
}
|
||||
|
||||
.progress-bar--striped--green {
|
||||
@include striped-background(var(--color-green));
|
||||
@extend %progress-bar--striped-common;
|
||||
}
|
||||
|
||||
.progress-bar--striped--red {
|
||||
@include striped-background(var(--color-red));
|
||||
@extend %progress-bar--striped-common;
|
||||
}
|
||||
|
||||
.progress-bar--striped--orange {
|
||||
@include striped-background(var(--color-orange));
|
||||
@extend %progress-bar--striped-common;
|
||||
}
|
||||
|
||||
.progress-bar--striped--blue {
|
||||
@include striped-background(var(--color-blue));
|
||||
@extend %progress-bar--striped-common;
|
||||
}
|
||||
|
||||
.progress-bar--striped--purple {
|
||||
@include striped-background(var(--color-purple));
|
||||
@extend %progress-bar--striped-common;
|
||||
}
|
||||
|
||||
.progress-bar--striped--gray {
|
||||
@include striped-background(var(--color-divider-dark));
|
||||
@extend %progress-bar--striped-common;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -127,6 +127,11 @@ export { default as ThemeSelector } from './settings/ThemeSelector.vue'
|
||||
|
||||
// Servers
|
||||
export { default as ServersSpecs } from './billing/ServersSpecs.vue'
|
||||
export { default as BackupCreateModal } from './servers/backups/BackupCreateModal.vue'
|
||||
export { default as BackupDeleteModal } from './servers/backups/BackupDeleteModal.vue'
|
||||
export { default as BackupItem } from './servers/backups/BackupItem.vue'
|
||||
export { default as BackupRenameModal } from './servers/backups/BackupRenameModal.vue'
|
||||
export { default as BackupRestoreModal } from './servers/backups/BackupRestoreModal.vue'
|
||||
export { default as BackupWarning } from './servers/backups/BackupWarning.vue'
|
||||
export { default as LoaderIcon } from './servers/icons/LoaderIcon.vue'
|
||||
export { default as ServerIcon } from './servers/icons/ServerIcon.vue'
|
||||
|
||||
@@ -10,12 +10,14 @@
|
||||
@click="() => (closeOnClickOutside && closable ? hide() : {})"
|
||||
/>
|
||||
<div
|
||||
:class="{
|
||||
shown: visible,
|
||||
noblur: props.noblur,
|
||||
danger: danger,
|
||||
}"
|
||||
class="modal-overlay"
|
||||
:class="[
|
||||
'modal-overlay',
|
||||
{
|
||||
shown: visible,
|
||||
noblur: props.noblur,
|
||||
},
|
||||
computedFade,
|
||||
]"
|
||||
@click="() => (closeOnClickOutside && closable ? hide() : {})"
|
||||
/>
|
||||
<div class="modal-container experimental-styles-within" :class="{ shown: visible }">
|
||||
@@ -106,7 +108,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useScrollIndicator } from '../../composables/scroll-indicator'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
@@ -115,7 +117,9 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
noblur?: boolean
|
||||
closable?: boolean
|
||||
/** @deprecated Use `fade="danger"` instead */
|
||||
danger?: boolean
|
||||
fade?: 'standard' | 'warning' | 'danger'
|
||||
closeOnEsc?: boolean
|
||||
closeOnClickOutside?: boolean
|
||||
warnOnClose?: boolean
|
||||
@@ -131,6 +135,7 @@ const props = withDefaults(
|
||||
type: true,
|
||||
closable: true,
|
||||
danger: false,
|
||||
fade: undefined,
|
||||
closeOnClickOutside: true,
|
||||
closeOnEsc: true,
|
||||
warnOnClose: false,
|
||||
@@ -145,6 +150,12 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
const computedFade = computed(() => {
|
||||
if (props.fade) return props.fade
|
||||
if (props.danger) return 'danger'
|
||||
return 'standard'
|
||||
})
|
||||
|
||||
const open = ref(false)
|
||||
const visible = ref(false)
|
||||
|
||||
@@ -225,7 +236,6 @@ function handleKeyDown(event: KeyboardEvent) {
|
||||
z-index: 19;
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-out;
|
||||
background: linear-gradient(to bottom, rgba(29, 48, 43, 0.52) 0%, rgba(14, 21, 26, 0.95) 100%);
|
||||
//transform: translate(
|
||||
// calc((-50vw + var(--_mouse-x, 50vw) * 1px) / 2),
|
||||
// calc((-50vh + var(--_mouse-y, 50vh) * 1px) / 2)
|
||||
@@ -234,6 +244,19 @@ function handleKeyDown(event: KeyboardEvent) {
|
||||
border-radius: 180px;
|
||||
//filter: blur(5px);
|
||||
|
||||
// Fade variants
|
||||
&.standard {
|
||||
background: linear-gradient(to bottom, rgba(29, 48, 43, 0.52) 0%, rgba(14, 21, 26, 0.95) 100%);
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background: linear-gradient(to bottom, rgba(48, 38, 29, 0.52) 0%, rgba(26, 20, 14, 0.95) 100%);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background: linear-gradient(to bottom, rgba(43, 18, 26, 0.52) 0%, rgba(49, 10, 15, 0.95) 100%);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
@@ -248,10 +271,6 @@ function handleKeyDown(event: KeyboardEvent) {
|
||||
backdrop-filter: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background: linear-gradient(to bottom, rgba(43, 18, 26, 0.52) 0%, rgba(49, 10, 15, 0.95) 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.modrinth-parent__no-modal-blurs {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
158
packages/ui/src/components/servers/backups/BackupCreateModal.vue
Normal file
158
packages/ui/src/components/servers/backups/BackupCreateModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
363
packages/ui/src/components/servers/backups/BackupItem.vue
Normal file
363
packages/ui/src/components/servers/backups/BackupItem.vue
Normal 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>
|
||||
@@ -23,8 +23,8 @@
|
||||
</div>
|
||||
<div class="mt-2 flex justify-start gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="isRenaming || nameExists" @click="renameBackup">
|
||||
<template v-if="isRenaming">
|
||||
<button :disabled="renameMutation.isPending.value || nameExists" @click="renameBackup">
|
||||
<template v-if="renameMutation.isPending.value">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
Renaming...
|
||||
</template>
|
||||
@@ -45,41 +45,61 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { IssuesIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, injectNotificationManager, NewModal } from '@modrinth/ui'
|
||||
import type { Backup } from '@modrinth/utils'
|
||||
import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
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<{
|
||||
server: ModrinthServer
|
||||
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 isRenaming = ref(false)
|
||||
|
||||
const currentBackup = ref<Backup | null>(null)
|
||||
const currentBackup = ref<Archon.Backups.v1.Backup | null>(null)
|
||||
|
||||
const trimmedName = computed(() => backupName.value.trim())
|
||||
|
||||
const nameExists = computed(() => {
|
||||
if (!props.server.backups?.data || trimmedName.value === originalName.value || isRenaming.value) {
|
||||
if (
|
||||
!props.backups ||
|
||||
trimmedName.value === originalName.value ||
|
||||
renameMutation.isPending.value
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return props.server.backups.data.some(
|
||||
(backup: Backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
|
||||
return props.backups.some(
|
||||
(backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
|
||||
)
|
||||
})
|
||||
|
||||
const backupNumber = computed(
|
||||
() => (props.server.backups?.data?.findIndex((b) => b.id === currentBackup.value?.id) ?? 0) + 1,
|
||||
() => (props.backups?.findIndex((b) => b.id === currentBackup.value?.id) ?? 0) + 1,
|
||||
)
|
||||
|
||||
const focusInput = () => {
|
||||
@@ -90,11 +110,10 @@ const focusInput = () => {
|
||||
})
|
||||
}
|
||||
|
||||
function show(backup: Backup) {
|
||||
function show(backup: Archon.Backups.v1.Backup) {
|
||||
currentBackup.value = backup
|
||||
backupName.value = backup.name
|
||||
originalName.value = backup.name
|
||||
isRenaming.value = false
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
@@ -102,7 +121,7 @@ function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
const renameBackup = async () => {
|
||||
const renameBackup = () => {
|
||||
if (!currentBackup.value) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
@@ -117,24 +136,24 @@ const renameBackup = async () => {
|
||||
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
|
||||
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({
|
||||
@@ -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>
|
||||
6
packages/ui/src/components/servers/backups/index.ts
Normal file
6
packages/ui/src/components/servers/backups/index.ts
Normal 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'
|
||||
@@ -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()
|
||||
|
||||
@@ -842,6 +842,51 @@
|
||||
"search.filter_type.shader_loader": {
|
||||
"defaultMessage": "Loader"
|
||||
},
|
||||
"servers.backups.item.auto": {
|
||||
"defaultMessage": "Auto"
|
||||
},
|
||||
"servers.backups.item.backup-schedule": {
|
||||
"defaultMessage": "Backup schedule"
|
||||
},
|
||||
"servers.backups.item.creating-backup": {
|
||||
"defaultMessage": "Creating backup..."
|
||||
},
|
||||
"servers.backups.item.failed-to-create-backup": {
|
||||
"defaultMessage": "Failed to create backup"
|
||||
},
|
||||
"servers.backups.item.failed-to-restore-backup": {
|
||||
"defaultMessage": "Failed to restore from backup"
|
||||
},
|
||||
"servers.backups.item.lock": {
|
||||
"defaultMessage": "Lock"
|
||||
},
|
||||
"servers.backups.item.locked": {
|
||||
"defaultMessage": "Locked"
|
||||
},
|
||||
"servers.backups.item.manual-backup": {
|
||||
"defaultMessage": "Manual backup"
|
||||
},
|
||||
"servers.backups.item.queued-for-backup": {
|
||||
"defaultMessage": "Backup queued"
|
||||
},
|
||||
"servers.backups.item.queued-for-restore": {
|
||||
"defaultMessage": "Restore queued"
|
||||
},
|
||||
"servers.backups.item.rename": {
|
||||
"defaultMessage": "Rename"
|
||||
},
|
||||
"servers.backups.item.restore": {
|
||||
"defaultMessage": "Restore"
|
||||
},
|
||||
"servers.backups.item.restoring-backup": {
|
||||
"defaultMessage": "Restoring from backup..."
|
||||
},
|
||||
"servers.backups.item.retry": {
|
||||
"defaultMessage": "Retry"
|
||||
},
|
||||
"servers.backups.item.unlock": {
|
||||
"defaultMessage": "Unlock"
|
||||
},
|
||||
"servers.notice.dismiss": {
|
||||
"defaultMessage": "Dismiss"
|
||||
},
|
||||
|
||||
460
packages/ui/src/pages/hosting/manage/backups.vue
Normal file
460
packages/ui/src/pages/hosting/manage/backups.vue
Normal file
@@ -0,0 +1,460 @@
|
||||
<template>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div
|
||||
v-if="error"
|
||||
key="error"
|
||||
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">{{ error.message }}</span>
|
||||
</p>
|
||||
<ButtonStyled size="large" color="brand" @click="refetch">
|
||||
<button class="mt-6 !w-full">Retry</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else key="content" class="contents">
|
||||
<BackupCreateModal ref="createBackupModal" :backups="backupsData ?? []" />
|
||||
<BackupRenameModal ref="renameBackupModal" :backups="backupsData ?? []" />
|
||||
<BackupRestoreModal ref="restoreBackupModal" />
|
||||
<BackupDeleteModal ref="deleteBackupModal" @delete="deleteBackup" />
|
||||
|
||||
<div class="mb-2 flex items-center align-middle justify-between">
|
||||
<span class="text-2xl font-semibold text-contrast">Backups</span>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="backupCreationDisabled"
|
||||
:disabled="!!backupCreationDisabled"
|
||||
@click="showCreateModel"
|
||||
>
|
||||
<PlusIcon class="size-5" />
|
||||
Create backup
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-1.5">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div
|
||||
v-if="groupedBackups.length === 0"
|
||||
key="empty"
|
||||
class="mt-6 flex items-center justify-center gap-2 text-center text-secondary"
|
||||
>
|
||||
<template v-if="server.used_backup_quota">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
Loading backups...
|
||||
</template>
|
||||
<template v-else>You don't have any backups yet.</template>
|
||||
</div>
|
||||
|
||||
<div v-else key="list" class="flex flex-col gap-1.5">
|
||||
<template v-for="group in groupedBackups" :key="group.label">
|
||||
<div class="flex items-center gap-2">
|
||||
<component :is="group.icon" v-if="group.icon" class="size-6 text-secondary" />
|
||||
<span class="text-lg font-semibold text-secondary">{{ group.label }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex w-5 justify-center">
|
||||
<div class="h-full w-px bg-surface-5" />
|
||||
</div>
|
||||
|
||||
<TransitionGroup name="list" tag="div" class="flex flex-1 flex-col gap-3 py-3">
|
||||
<BackupItem
|
||||
v-for="backup in group.backups"
|
||||
:key="`backup-${backup.id}`"
|
||||
:backup="backup"
|
||||
:disabled="backupOperationDisabled"
|
||||
:kyros-url="server.node?.instance"
|
||||
:jwt="server.node?.token"
|
||||
:show-debug-info="showDebugInfo"
|
||||
@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)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</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>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { CalendarIcon, DownloadIcon, IssuesIcon, PlusIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import dayjs from 'dayjs'
|
||||
import type { Component } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import ButtonStyled from '../../../components/base/ButtonStyled.vue'
|
||||
import BackupCreateModal from '../../../components/servers/backups/BackupCreateModal.vue'
|
||||
import BackupDeleteModal from '../../../components/servers/backups/BackupDeleteModal.vue'
|
||||
import BackupItem from '../../../components/servers/backups/BackupItem.vue'
|
||||
import BackupRenameModal from '../../../components/servers/backups/BackupRenameModal.vue'
|
||||
import BackupRestoreModal from '../../../components/servers/backups/BackupRestoreModal.vue'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
} from '../../../providers'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const { server, backupsState, markBackupCancelled } = injectModrinthServerContext()
|
||||
|
||||
const props = defineProps<{
|
||||
isServerRunning: boolean
|
||||
showDebugInfo?: boolean
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const serverId = route.params.id as string
|
||||
|
||||
defineEmits(['onDownload'])
|
||||
|
||||
const backupsQueryKey = ['backups', 'list', serverId]
|
||||
const {
|
||||
data: backupsData,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: backupsQueryKey,
|
||||
queryFn: () => client.archon.backups_v0.list(serverId),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (backupId: string) => client.archon.backups_v0.delete(serverId, backupId),
|
||||
onSuccess: (_data, backupId) => {
|
||||
markBackupCancelled(backupId)
|
||||
backupsState.delete(backupId)
|
||||
queryClient.invalidateQueries({ queryKey: backupsQueryKey })
|
||||
},
|
||||
})
|
||||
|
||||
const lockMutation = useMutation({
|
||||
mutationFn: (backupId: string) => client.archon.backups_v0.lock(serverId, backupId),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
|
||||
})
|
||||
|
||||
const unlockMutation = useMutation({
|
||||
mutationFn: (backupId: string) => client.archon.backups_v0.unlock(serverId, backupId),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
|
||||
})
|
||||
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: (backupId: string) => client.archon.backups_v0.retry(serverId, backupId),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
|
||||
})
|
||||
|
||||
const backups = computed(() => {
|
||||
if (!backupsData.value) return []
|
||||
|
||||
const merged = backupsData.value.map((backup) => {
|
||||
const progressState = backupsState.get(backup.id)
|
||||
if (progressState) {
|
||||
const hasOngoingTask = Object.values(progressState).some((task) => task?.state === 'ongoing')
|
||||
const hasCompletedTask = Object.values(progressState).some((task) => task?.state === 'done')
|
||||
|
||||
return {
|
||||
...backup,
|
||||
task: {
|
||||
...backup.task,
|
||||
...progressState,
|
||||
},
|
||||
|
||||
ongoing: hasOngoingTask || (backup.ongoing && !hasCompletedTask),
|
||||
}
|
||||
}
|
||||
return backup
|
||||
})
|
||||
|
||||
return merged.sort((a, b) => {
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
})
|
||||
})
|
||||
|
||||
type BackupGroup = {
|
||||
label: string
|
||||
icon: Component | null
|
||||
backups: Archon.Backups.v1.Backup[]
|
||||
}
|
||||
|
||||
const groupedBackups = computed((): BackupGroup[] => {
|
||||
if (!backups.value.length) return []
|
||||
|
||||
const now = dayjs()
|
||||
const groups: BackupGroup[] = []
|
||||
|
||||
const addToGroup = (label: string, icon: Component | null, backup: Archon.Backups.v1.Backup) => {
|
||||
let group = groups.find((g) => g.label === label)
|
||||
if (!group) {
|
||||
group = { label, icon, backups: [] }
|
||||
groups.push(group)
|
||||
}
|
||||
group.backups.push(backup)
|
||||
}
|
||||
|
||||
for (const backup of backups.value) {
|
||||
const created = dayjs(backup.created_at)
|
||||
const diffMinutes = now.diff(created, 'minute')
|
||||
const isToday = created.isSame(now, 'day')
|
||||
const isYesterday = created.isSame(now.subtract(1, 'day'), 'day')
|
||||
const diffDays = now.diff(created, 'day')
|
||||
|
||||
if (diffMinutes < 30 && isToday) {
|
||||
addToGroup('Just now', null, backup)
|
||||
} else if (isToday) {
|
||||
addToGroup('Earlier today', CalendarIcon, backup)
|
||||
} else if (isYesterday) {
|
||||
addToGroup('Yesterday', CalendarIcon, backup)
|
||||
} else if (diffDays <= 14) {
|
||||
addToGroup('Last 2 weeks', CalendarIcon, backup)
|
||||
} else {
|
||||
addToGroup('Older', CalendarIcon, backup)
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
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 backupOperationDisabled = computed(() => {
|
||||
if (props.isServerRunning) {
|
||||
return 'Cannot perform backup operations while server is running'
|
||||
}
|
||||
for (const entry of backupsState.values()) {
|
||||
if (entry.create?.state === 'ongoing') {
|
||||
return 'Cannot perform backup operations while a backup is being created'
|
||||
}
|
||||
if (entry.restore?.state === 'ongoing') {
|
||||
return 'Cannot perform backup operations while a backup is being restored'
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const backupCreationDisabled = computed(() => {
|
||||
if (props.isServerRunning) {
|
||||
return 'Cannot create backup while server is running'
|
||||
}
|
||||
if (
|
||||
server.value.used_backup_quota !== undefined &&
|
||||
server.value.backup_quota !== undefined &&
|
||||
server.value.used_backup_quota >= server.value.backup_quota
|
||||
) {
|
||||
return `All ${server.value.backup_quota} of your backup slots are in use`
|
||||
}
|
||||
|
||||
for (const entry of backupsState.values()) {
|
||||
if (entry.create?.state === 'ongoing') {
|
||||
return 'A backup is already in progress'
|
||||
}
|
||||
if (entry.restore?.state === 'ongoing') {
|
||||
return 'Cannot create backup while a restore is in progress'
|
||||
}
|
||||
}
|
||||
if (server.value.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 = (backupId: string) => {
|
||||
lockMutation.mutate(backupId, {
|
||||
onError: (err) => {
|
||||
console.error('Failed to lock backup:', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const unlockBackup = (backupId: string) => {
|
||||
unlockMutation.mutate(backupId, {
|
||||
onError: (err) => {
|
||||
console.error('Failed to unlock backup:', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const retryBackup = (backupId: string) => {
|
||||
retryMutation.mutate(backupId, {
|
||||
onError: (err) => {
|
||||
console.error('Failed to retry backup:', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function deleteBackup(backup?: Archon.Backups.v1.Backup) {
|
||||
if (!backup) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Error deleting backup',
|
||||
text: 'Backup is null',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
deleteMutation.mutate(backup.id, {
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Error deleting backup',
|
||||
text: message,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition:
|
||||
opacity 300ms ease-in-out,
|
||||
transform 300ms ease-in-out;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 200ms ease-in-out;
|
||||
}
|
||||
|
||||
.list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.list-move {
|
||||
transition: transform 200ms ease-in-out;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -1 +1,2 @@
|
||||
export { default as ServersManageBackupsPage } from './hosting/manage/backups.vue'
|
||||
export { default as ServersManagePageIndex } from './hosting/manage/index.vue'
|
||||
|
||||
@@ -80,4 +80,5 @@ export function createContext<ContextValue>(
|
||||
|
||||
export * from './api-client'
|
||||
export * from './project-page'
|
||||
export * from './server-context'
|
||||
export * from './web-notifications'
|
||||
|
||||
32
packages/ui/src/providers/server-context.ts
Normal file
32
packages/ui/src/providers/server-context.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import type { ComputedRef, Reactive, Ref } from 'vue'
|
||||
|
||||
import { createContext } from '.'
|
||||
|
||||
export type BackupTaskState = {
|
||||
progress: number
|
||||
state: Archon.Backups.v1.BackupState
|
||||
}
|
||||
|
||||
export type BackupProgressEntry = {
|
||||
file?: BackupTaskState
|
||||
create?: BackupTaskState
|
||||
restore?: BackupTaskState
|
||||
}
|
||||
|
||||
export type BackupsState = Map<string, BackupProgressEntry>
|
||||
|
||||
export interface ModrinthServerContext {
|
||||
readonly serverId: string
|
||||
readonly server: Ref<Archon.Servers.v0.Server>
|
||||
|
||||
// Websocket state
|
||||
readonly isConnected: Ref<boolean>
|
||||
readonly powerState: Ref<Archon.Websocket.v0.PowerState>
|
||||
readonly isServerRunning: ComputedRef<boolean>
|
||||
readonly backupsState: Reactive<BackupsState>
|
||||
markBackupCancelled: (backupId: string) => void
|
||||
}
|
||||
|
||||
export const [injectModrinthServerContext, provideModrinthServerContext] =
|
||||
createContext<ModrinthServerContext>('[id].vue', 'modrinthServerContext')
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -398,6 +398,9 @@ importers:
|
||||
'@tauri-apps/plugin-http':
|
||||
specifier: ^2.0.0
|
||||
version: 2.5.0
|
||||
mitt:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
ofetch:
|
||||
specifier: ^1.4.1
|
||||
version: 1.4.1
|
||||
|
||||
Reference in New Issue
Block a user