feat: backups alignment with Figma (#5559)
* feat: backup admonitions * feat: align modals + fix backupitem * fix: body needs opac 80 * fix: lint
This commit is contained in:
@@ -32,7 +32,7 @@
|
||||
:options="languageOptions"
|
||||
searchable
|
||||
include-select-all-option
|
||||
:maxTagRows="2"
|
||||
:max-tag-rows="2"
|
||||
placeholder="Select languages"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
|
||||
@@ -311,6 +311,7 @@
|
||||
</template>
|
||||
</InstallingBanner>
|
||||
</Transition>
|
||||
<BackupProgressAdmonitions class="mb-4" />
|
||||
<NuxtPage
|
||||
:route="route"
|
||||
:is-connected="isConnected"
|
||||
@@ -357,6 +358,7 @@ import {
|
||||
} from '@modrinth/assets'
|
||||
import type { BusyReason } from '@modrinth/ui'
|
||||
import {
|
||||
BackupProgressAdmonitions,
|
||||
ButtonStyled,
|
||||
defineMessage,
|
||||
ErrorInformationCard,
|
||||
|
||||
@@ -52,7 +52,7 @@ import ButtonStyled from './ButtonStyled.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
type?: 'info' | 'warning' | 'critical'
|
||||
type?: 'info' | 'warning' | 'critical' | 'success'
|
||||
header?: string
|
||||
body?: string
|
||||
showActionsUnderneath?: boolean
|
||||
@@ -75,17 +75,20 @@ const typeClasses = {
|
||||
info: 'border-brand-blue bg-bg-blue',
|
||||
warning: 'border-brand-orange bg-bg-orange',
|
||||
critical: 'border-brand-red bg-bg-red',
|
||||
success: 'border-brand-green bg-bg-green',
|
||||
}
|
||||
|
||||
const iconClasses = {
|
||||
info: 'text-brand-blue',
|
||||
warning: 'text-brand-orange',
|
||||
critical: 'text-brand-red',
|
||||
success: 'text-brand-green',
|
||||
}
|
||||
|
||||
const buttonColors: Record<string, 'blue' | 'orange' | 'red'> = {
|
||||
const buttonColors: Record<string, 'blue' | 'orange' | 'red' | 'green'> = {
|
||||
info: 'blue',
|
||||
warning: 'orange',
|
||||
critical: 'red',
|
||||
success: 'green',
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Delete backup" fade="danger">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-6 max-w-[400px]">
|
||||
<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"
|
||||
/>
|
||||
<BackupItem :backup="currentBackup" preview class="!bg-surface-2 !shadow-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import { defineMessages, useVIntl } from '../../../composables/i18n'
|
||||
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 formatDateTime = useFormatDateTime({
|
||||
@@ -48,12 +47,6 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
const backupQueued = computed(
|
||||
() =>
|
||||
props.backup.status === 'pending' ||
|
||||
props.backup.task?.create?.progress === 0 ||
|
||||
(props.backup.status === 'in_progress' && !props.backup.task?.create),
|
||||
)
|
||||
const failedToCreate = computed(
|
||||
() => props.backup.status === 'error' || props.backup.status === 'timed_out',
|
||||
)
|
||||
@@ -63,30 +56,30 @@ 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
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
(props.backup.status === 'in_progress' || props.backup.status === 'pending') &&
|
||||
!props.backup.task?.restore
|
||||
) {
|
||||
return { progress: 0, state: 'ongoing' as const }
|
||||
return true
|
||||
}
|
||||
return undefined
|
||||
return false
|
||||
})
|
||||
|
||||
const restoring = computed(() => {
|
||||
const task = props.backup.task?.restore
|
||||
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
|
||||
return task
|
||||
return true
|
||||
}
|
||||
return undefined
|
||||
return false
|
||||
})
|
||||
|
||||
const restoreQueued = computed(() => restoring.value?.progress === 0)
|
||||
|
||||
const failedToRestore = computed(() => props.backup.task?.restore?.state === 'failed')
|
||||
|
||||
const activeOperation = computed(() => creating.value || restoring.value)
|
||||
|
||||
const backupIcon = computed(() => {
|
||||
if (props.backup.automated) {
|
||||
return ClockIcon
|
||||
@@ -97,8 +90,7 @@ const backupIcon = computed(() => {
|
||||
const overflowMenuOptions = computed<OverflowOption[]>(() => {
|
||||
const options: OverflowOption[] = []
|
||||
|
||||
// Download only available when not creating
|
||||
if (!creating.value) {
|
||||
if (!activeOperation.value) {
|
||||
options.push({
|
||||
id: 'download',
|
||||
action: () => emit('download'),
|
||||
@@ -109,8 +101,7 @@ const overflowMenuOptions = computed<OverflowOption[]>(() => {
|
||||
|
||||
options.push({ id: 'rename', action: () => emit('rename') })
|
||||
|
||||
// Delete only available when not creating (has separate Cancel button)
|
||||
if (!creating.value) {
|
||||
if (!activeOperation.value) {
|
||||
options.push({ divider: true })
|
||||
options.push({
|
||||
id: 'delete',
|
||||
@@ -138,22 +129,6 @@ const messages = defineMessages({
|
||||
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',
|
||||
@@ -179,7 +154,7 @@ const messages = defineMessages({
|
||||
<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]'"
|
||||
:class="preview ? 'grid-cols-1' : 'grid-cols-[auto_1fr_auto] md:grid-cols-[1fr_400px_1fr]'"
|
||||
>
|
||||
<div class="flex flex-row gap-4 items-center">
|
||||
<div
|
||||
@@ -199,7 +174,10 @@ const messages = defineMessages({
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-sm text-secondary">
|
||||
<template v-if="failedToCreate || failedToRestore">
|
||||
<template v-if="preview">
|
||||
<span>{{ formatDateTime(backup.created_at) }}</span>
|
||||
</template>
|
||||
<template v-else-if="failedToCreate || failedToRestore">
|
||||
<XIcon class="size-4 text-red" />
|
||||
<span class="text-red">
|
||||
{{
|
||||
@@ -228,95 +206,42 @@ const messages = defineMessages({
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!preview"
|
||||
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">
|
||||
{{ formatDateTime(backup.created_at) }}
|
||||
</span>
|
||||
<!-- TODO: Uncomment when API supports size field -->
|
||||
<!-- <span class="text-secondary">{{ formatBytes(backup.size) }}</span> -->
|
||||
</template>
|
||||
<span class="w-full font-medium text-contrast md:text-center">
|
||||
{{ formatDateTime(backup.created_at) }}
|
||||
</span>
|
||||
<!-- TODO: Uncomment when API supports size field -->
|
||||
<!-- <span class="text-secondary">{{ formatBytes(backup.size) }}</span> -->
|
||||
</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(commonMessages.retryButton) }}
|
||||
</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>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ButtonStyled color="brand" type="outlined">
|
||||
<button
|
||||
v-tooltip="props.restoreDisabled"
|
||||
class="!border-[1px]"
|
||||
:disabled="!!props.restoreDisabled"
|
||||
@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 #delete>
|
||||
<TrashIcon class="size-5" /> {{ formatMessage(commonMessages.deleteLabel) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<ButtonStyled v-if="!activeOperation" color="brand" type="outlined">
|
||||
<button
|
||||
v-tooltip="props.restoreDisabled"
|
||||
class="!border-[1px]"
|
||||
:disabled="!!props.restoreDisabled"
|
||||
@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 #delete>
|
||||
<TrashIcon class="size-5" /> {{ formatMessage(commonMessages.deleteLabel) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<pre v-if="!preview && showDebugInfo" class="w-full rounded-xl bg-surface-4 p-2 text-xs">{{
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
InfoIcon,
|
||||
RotateCounterClockwiseIcon,
|
||||
TriangleAlertIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useRelativeTime } from '../../../composables'
|
||||
import { defineMessages, useVIntl } from '../../../composables/i18n'
|
||||
import { commonMessages } from '../../../utils'
|
||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import ProgressBar from '../../base/ProgressBar.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const relativeTime = useRelativeTime()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
type: 'create' | 'restore'
|
||||
state: Archon.Backups.v1.BackupState
|
||||
progress: number
|
||||
backupName?: string
|
||||
createdAt?: string
|
||||
}>(),
|
||||
{
|
||||
backupName: undefined,
|
||||
createdAt: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancel' | 'retry' | 'dismiss'): void
|
||||
}>()
|
||||
|
||||
const isQueued = computed(() => props.state === 'ongoing' && props.progress === 0)
|
||||
const isInProgress = computed(() => props.state === 'ongoing' && props.progress > 0)
|
||||
const isFailed = computed(() => props.state === 'failed')
|
||||
const isSuccess = computed(() => props.state === 'done')
|
||||
|
||||
const showCancel = computed(() => isQueued.value || isInProgress.value)
|
||||
const showRetry = computed(() => isFailed.value)
|
||||
const showDismiss = computed(() => isFailed.value || isSuccess.value)
|
||||
const showProgress = computed(() => isInProgress.value)
|
||||
|
||||
const colorClasses = computed(() => {
|
||||
if (isFailed.value) return 'border-brand-red bg-bg-red'
|
||||
if (isSuccess.value) return 'border-brand-green bg-bg-green'
|
||||
return 'border-brand-blue bg-bg-blue'
|
||||
})
|
||||
|
||||
const icon = computed(() => {
|
||||
if (isFailed.value) return TriangleAlertIcon
|
||||
if (isSuccess.value) return CheckCircleIcon
|
||||
return InfoIcon
|
||||
})
|
||||
|
||||
const iconClass = computed(() => {
|
||||
if (isFailed.value) return 'text-brand-red'
|
||||
if (isSuccess.value) return 'text-brand-green'
|
||||
return 'text-brand-blue'
|
||||
})
|
||||
|
||||
const buttonColor = computed<'red' | 'green' | 'blue'>(() => {
|
||||
if (isFailed.value) return 'red'
|
||||
if (isSuccess.value) return 'green'
|
||||
return 'blue'
|
||||
})
|
||||
|
||||
const name = computed(() => props.backupName ?? formatMessage(messages.fallbackName))
|
||||
|
||||
const title = computed(() => {
|
||||
if (props.type === 'create') {
|
||||
if (isQueued.value) return formatMessage(messages.backupQueuedTitle)
|
||||
if (isInProgress.value) return formatMessage(messages.creatingBackupTitle)
|
||||
if (isFailed.value) return formatMessage(messages.backupFailedTitle)
|
||||
}
|
||||
if (isQueued.value) return formatMessage(messages.restoreQueuedTitle)
|
||||
if (isInProgress.value) return formatMessage(messages.restoringBackupTitle)
|
||||
if (isSuccess.value) return formatMessage(messages.restoreSuccessfulTitle)
|
||||
if (isFailed.value) return formatMessage(messages.restoreFailedTitle)
|
||||
return ''
|
||||
})
|
||||
|
||||
const description = computed(() => {
|
||||
if (props.type === 'create') {
|
||||
if (isQueued.value)
|
||||
return formatMessage(messages.backupQueuedDescription, { backupName: name.value })
|
||||
if (isInProgress.value)
|
||||
return formatMessage(messages.creatingBackupDescription, { backupName: name.value })
|
||||
if (isFailed.value)
|
||||
return formatMessage(messages.backupFailedDescription, { backupName: name.value })
|
||||
}
|
||||
if (isQueued.value)
|
||||
return formatMessage(messages.restoreQueuedDescription, { backupName: name.value })
|
||||
if (isInProgress.value)
|
||||
return formatMessage(messages.restoringBackupDescription, { backupName: name.value })
|
||||
if (isSuccess.value)
|
||||
return formatMessage(messages.restoreSuccessfulDescription, { backupName: name.value })
|
||||
if (isFailed.value)
|
||||
return formatMessage(messages.restoreFailedDescription, { backupName: name.value })
|
||||
return ''
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
fallbackName: {
|
||||
id: 'servers.backups.admonition.fallback-name',
|
||||
defaultMessage: 'your backup',
|
||||
},
|
||||
backupQueuedTitle: {
|
||||
id: 'servers.backups.admonition.backup-queued.title',
|
||||
defaultMessage: 'Backup queued',
|
||||
},
|
||||
backupQueuedDescription: {
|
||||
id: 'servers.backups.admonition.backup-queued.description',
|
||||
defaultMessage: '{backupName} is queued and will start shortly.',
|
||||
},
|
||||
creatingBackupTitle: {
|
||||
id: 'servers.backups.admonition.creating-backup.title',
|
||||
defaultMessage: 'Creating backup',
|
||||
},
|
||||
creatingBackupDescription: {
|
||||
id: 'servers.backups.admonition.creating-backup.description',
|
||||
defaultMessage:
|
||||
'Saving world data and server configuration for {backupName}. This can take a few minutes.',
|
||||
},
|
||||
backupFailedTitle: {
|
||||
id: 'servers.backups.admonition.backup-failed.title',
|
||||
defaultMessage: 'Backup failed',
|
||||
},
|
||||
backupFailedDescription: {
|
||||
id: 'servers.backups.admonition.backup-failed.description',
|
||||
defaultMessage:
|
||||
'Something went wrong while creating {backupName}. Please try again or contact support if the issue continues.',
|
||||
},
|
||||
restoreQueuedTitle: {
|
||||
id: 'servers.backups.admonition.restore-queued.title',
|
||||
defaultMessage: 'Restoring from backup queued',
|
||||
},
|
||||
restoreQueuedDescription: {
|
||||
id: 'servers.backups.admonition.restore-queued.description',
|
||||
defaultMessage: 'Restoring from {backupName} is queued and will start shortly.',
|
||||
},
|
||||
restoringBackupTitle: {
|
||||
id: 'servers.backups.admonition.restoring-backup.title',
|
||||
defaultMessage: 'Restoring from backup',
|
||||
},
|
||||
restoringBackupDescription: {
|
||||
id: 'servers.backups.admonition.restoring-backup.description',
|
||||
defaultMessage: 'Restoring your server from {backupName}. This may take a couple of minutes.',
|
||||
},
|
||||
restoreSuccessfulTitle: {
|
||||
id: 'servers.backups.admonition.restore-successful.title',
|
||||
defaultMessage: 'Restoring from backup successful',
|
||||
},
|
||||
restoreSuccessfulDescription: {
|
||||
id: 'servers.backups.admonition.restore-successful.description',
|
||||
defaultMessage: 'Your server has been restored to {backupName} and is ready to start.',
|
||||
},
|
||||
restoreFailedTitle: {
|
||||
id: 'servers.backups.admonition.restore-failed.title',
|
||||
defaultMessage: 'Restoring from backup failed',
|
||||
},
|
||||
restoreFailedDescription: {
|
||||
id: 'servers.backups.admonition.restore-failed.description',
|
||||
defaultMessage:
|
||||
'Something went wrong while restoring from {backupName}. Please try again or contact support if the issue continues.',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['flex flex-col rounded-2xl border border-solid p-4', colorClasses]">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex flex-1 gap-3 items-start">
|
||||
<component :is="icon" :class="['size-6 shrink-0', iconClass]" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-contrast">{{ title }}</span>
|
||||
<div v-if="createdAt" class="flex items-center gap-1.5 text-secondary">
|
||||
<ClockIcon class="size-4" />
|
||||
<span class="font-medium">{{ relativeTime(createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-contrast opacity-80">{{ description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<ButtonStyled v-if="showCancel" type="outlined" color="blue">
|
||||
<button class="!border" @click="emit('cancel')">
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="showRetry" color="red">
|
||||
<button @click="emit('retry')">
|
||||
<RotateCounterClockwiseIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.retryButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-if="showDismiss"
|
||||
circular
|
||||
type="transparent"
|
||||
hover-color-fill="background"
|
||||
:color="buttonColor"
|
||||
>
|
||||
<button @click="emit('dismiss')">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showProgress" class="mt-4 pl-9">
|
||||
<ProgressBar :progress="progress" color="blue" :waiting="progress === 0" full-width />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
|
||||
import { injectModrinthClient, injectModrinthServerContext } from '../../../providers'
|
||||
import type { BackupProgressEntry } from '../../../providers/server-context'
|
||||
import BackupProgressAdmonition from './BackupProgressAdmonition.vue'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const { serverId, worldId, backupsState, markBackupCancelled } = injectModrinthServerContext()
|
||||
|
||||
const backupsQueryKey = ['backups', 'list', serverId]
|
||||
|
||||
const { data: backupsList } = useQuery({
|
||||
queryKey: backupsQueryKey,
|
||||
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
|
||||
enabled: computed(() => !!worldId.value),
|
||||
})
|
||||
|
||||
interface TerminalEntry {
|
||||
type: 'create' | 'restore'
|
||||
state: Archon.Backups.v1.BackupState
|
||||
backupName?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
interface AdmonitionEntry {
|
||||
key: string
|
||||
backupId: string
|
||||
type: 'create' | 'restore'
|
||||
state: Archon.Backups.v1.BackupState
|
||||
progress: number
|
||||
name?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
const terminalEntries = reactive(new Map<string, TerminalEntry>())
|
||||
const dismissedIds = reactive(new Set<string>())
|
||||
|
||||
function findBackup(backupId: string) {
|
||||
return backupsList.value?.find((b) => b.id === backupId)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [...backupsState.entries()] as [string, BackupProgressEntry][],
|
||||
(entries) => {
|
||||
for (const [id, entry] of entries) {
|
||||
const backup = findBackup(id)
|
||||
if (entry.create?.state === 'failed') {
|
||||
terminalEntries.set(`${id}:create`, {
|
||||
type: 'create',
|
||||
state: 'failed',
|
||||
backupName: backup?.name,
|
||||
createdAt: backup?.created_at,
|
||||
})
|
||||
}
|
||||
if (entry.restore?.state === 'done') {
|
||||
terminalEntries.set(`${id}:restore`, {
|
||||
type: 'restore',
|
||||
state: 'done',
|
||||
backupName: backup?.name,
|
||||
createdAt: backup?.created_at,
|
||||
})
|
||||
}
|
||||
if (entry.restore?.state === 'failed') {
|
||||
terminalEntries.set(`${id}:restore`, {
|
||||
type: 'restore',
|
||||
state: 'failed',
|
||||
backupName: backup?.name,
|
||||
createdAt: backup?.created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const admonitions = computed<AdmonitionEntry[]>(() => {
|
||||
const result: AdmonitionEntry[] = []
|
||||
const seenIds = new Set<string>()
|
||||
|
||||
// 1. Active WS entries (real-time progress from backupsState)
|
||||
for (const [id, entry] of backupsState.entries()) {
|
||||
const backup = findBackup(id)
|
||||
if (entry.create && entry.create.state === 'ongoing') {
|
||||
const key = `${id}:create`
|
||||
if (!dismissedIds.has(key)) {
|
||||
seenIds.add(id)
|
||||
result.push({
|
||||
key,
|
||||
backupId: id,
|
||||
type: 'create',
|
||||
state: entry.create.state,
|
||||
progress: entry.create.progress,
|
||||
name: backup?.name,
|
||||
createdAt: backup?.created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (entry.restore && entry.restore.state === 'ongoing') {
|
||||
const key = `${id}:restore`
|
||||
if (!dismissedIds.has(key)) {
|
||||
seenIds.add(id)
|
||||
result.push({
|
||||
key,
|
||||
backupId: id,
|
||||
type: 'restore',
|
||||
state: entry.restore.state,
|
||||
progress: entry.restore.progress,
|
||||
name: backup?.name,
|
||||
createdAt: backup?.created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. REST-based entries for pending/in_progress backups without WS data yet
|
||||
if (backupsList.value) {
|
||||
for (const backup of backupsList.value) {
|
||||
if (seenIds.has(backup.id)) continue
|
||||
if (backup.status === 'pending' || backup.status === 'in_progress') {
|
||||
const key = `${backup.id}:create`
|
||||
if (!dismissedIds.has(key)) {
|
||||
result.push({
|
||||
key,
|
||||
backupId: backup.id,
|
||||
type: 'create',
|
||||
state: 'ongoing',
|
||||
progress: 0,
|
||||
name: backup.name,
|
||||
createdAt: backup.created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Terminal entries (snapshotted before cleanup)
|
||||
for (const [key, entry] of terminalEntries.entries()) {
|
||||
if (dismissedIds.has(key)) continue
|
||||
if (result.some((r) => r.key === key)) continue
|
||||
|
||||
const backupId = key.split(':')[0]
|
||||
const backup = findBackup(backupId)
|
||||
result.push({
|
||||
key,
|
||||
backupId,
|
||||
type: entry.type,
|
||||
state: entry.state,
|
||||
progress: entry.state === 'done' ? 1 : 0,
|
||||
name: backup?.name ?? entry.backupName,
|
||||
createdAt: backup?.created_at ?? entry.createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
function handleCancel(backupId: string) {
|
||||
client.archon.backups_v1.delete(serverId, worldId.value!, backupId).then(() => {
|
||||
markBackupCancelled(backupId)
|
||||
backupsState.delete(backupId)
|
||||
queryClient.invalidateQueries({ queryKey: backupsQueryKey })
|
||||
})
|
||||
}
|
||||
|
||||
function handleRetry(backupId: string, key: string) {
|
||||
client.archon.backups_v1.retry(serverId, worldId.value!, backupId).then(() => {
|
||||
terminalEntries.delete(key)
|
||||
dismissedIds.delete(key)
|
||||
queryClient.invalidateQueries({ queryKey: backupsQueryKey })
|
||||
})
|
||||
}
|
||||
|
||||
function handleDismiss(key: string) {
|
||||
dismissedIds.add(key)
|
||||
terminalEntries.delete(key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TransitionGroup
|
||||
v-if="admonitions.length > 0"
|
||||
name="backup-admonition"
|
||||
tag="div"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<BackupProgressAdmonition
|
||||
v-for="item in admonitions"
|
||||
:key="item.key"
|
||||
:type="item.type"
|
||||
:state="item.state"
|
||||
:progress="item.progress"
|
||||
:backup-name="item.name"
|
||||
:created-at="item.createdAt"
|
||||
@cancel="handleCancel(item.backupId)"
|
||||
@retry="handleRetry(item.backupId, item.key)"
|
||||
@dismiss="handleDismiss(item.key)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backup-admonition-enter-active,
|
||||
.backup-admonition-leave-active {
|
||||
transition:
|
||||
opacity 300ms ease-in-out,
|
||||
transform 300ms ease-in-out;
|
||||
}
|
||||
|
||||
.backup-admonition-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.backup-admonition-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.backup-admonition-move {
|
||||
transition: transform 300ms ease-in-out;
|
||||
}
|
||||
</style>
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Restore backup" fade="danger">
|
||||
<div class="flex flex-col gap-6 max-w-[600px]">
|
||||
<div class="flex flex-col gap-6 max-w-[400px]">
|
||||
<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 v-else type="critical" header="Restore warning">
|
||||
Restoring your server will replace the current world and server files. Any changes made
|
||||
since that backup will be permanently lost.
|
||||
</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" />
|
||||
<BackupItem :backup="currentBackup" preview class="!bg-surface-2 !shadow-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export { default as BackupCreateModal } from './BackupCreateModal.vue'
|
||||
export { default as BackupDeleteModal } from './BackupDeleteModal.vue'
|
||||
export { default as BackupItem } from './BackupItem.vue'
|
||||
export { default as BackupProgressAdmonition } from './BackupProgressAdmonition.vue'
|
||||
export { default as BackupProgressAdmonitions } from './BackupProgressAdmonitions.vue'
|
||||
export { default as BackupRenameModal } from './BackupRenameModal.vue'
|
||||
export { default as BackupRestoreModal } from './BackupRestoreModal.vue'
|
||||
export { default as BackupWarning } from './BackupWarning.vue'
|
||||
|
||||
@@ -144,6 +144,10 @@ const messages = defineMessages({
|
||||
id: 'content.page-layout.busy-description',
|
||||
defaultMessage: 'Please wait for the operation to complete before editing content.',
|
||||
},
|
||||
pleaseWait: {
|
||||
id: 'content.page-layout.please-wait',
|
||||
defaultMessage: 'Please wait',
|
||||
},
|
||||
})
|
||||
|
||||
const ctx = injectContentManager()
|
||||
@@ -493,7 +497,11 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
:categories="ctx.modpack.value.categories"
|
||||
:has-update="ctx.modpack.value.hasUpdate"
|
||||
:disabled="ctx.modpack.value.disabled || ctx.isBusy.value"
|
||||
:disabled-text="ctx.modpack.value.disabledText"
|
||||
:disabled-text="
|
||||
ctx.modpack.value.disabledText ??
|
||||
ctx.busyMessage?.value ??
|
||||
(ctx.isBusy.value ? formatMessage(messages.pleaseWait) : undefined)
|
||||
"
|
||||
:show-content-hint="
|
||||
!!(ctx.showContentHint?.value && ctx.modpack.value && ctx.items.value.length === 0)
|
||||
"
|
||||
|
||||
@@ -789,14 +789,21 @@ provideContentManager({
|
||||
isBusy: computed(() => busyReasons.value.length > 0),
|
||||
busyMessage: computed(() => {
|
||||
const bannerCoversInstalling = server.value?.status === 'installing' || isSyncingContent.value
|
||||
const nonBannerReasons = bannerCoversInstalling
|
||||
? busyReasons.value.filter(
|
||||
(r) =>
|
||||
r.reason.id !== 'servers.busy.installing' &&
|
||||
r.reason.id !== 'servers.busy.syncing-content',
|
||||
)
|
||||
: busyReasons.value
|
||||
return nonBannerReasons.length > 0 ? formatMessage(nonBannerReasons[0].reason) : null
|
||||
const filteredReasons = busyReasons.value.filter((r) => {
|
||||
if (
|
||||
bannerCoversInstalling &&
|
||||
(r.reason.id === 'servers.busy.installing' ||
|
||||
r.reason.id === 'servers.busy.syncing-content')
|
||||
)
|
||||
return false
|
||||
if (
|
||||
r.reason.id === 'servers.busy.backup-creating' ||
|
||||
r.reason.id === 'servers.busy.backup-restoring'
|
||||
)
|
||||
return false
|
||||
return true
|
||||
})
|
||||
return filteredReasons.length > 0 ? formatMessage(filteredReasons[0].reason) : null
|
||||
}),
|
||||
getItemId: (item) => item.file_name,
|
||||
contentTypeLabel: type,
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
</div>
|
||||
|
||||
<div v-else key="content" class="contents">
|
||||
<Admonition v-if="serverBusy" type="warning" class="mb-5">
|
||||
<template #header>{{ busyTooltip }}</template>
|
||||
<Admonition v-if="nonBackupBusyReasons.length > 0" type="warning" class="mb-5">
|
||||
<template #header>{{ formatMessage(nonBackupBusyReasons[0].reason) }}</template>
|
||||
File operations are disabled while the operation is in progress.
|
||||
</Admonition>
|
||||
<div class="relative flex w-full flex-col">
|
||||
@@ -327,6 +327,13 @@ const serverBusy = computed(() => busyReasons.value.length > 0)
|
||||
const busyTooltip = computed(() =>
|
||||
busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined,
|
||||
)
|
||||
const nonBackupBusyReasons = computed(() =>
|
||||
busyReasons.value.filter(
|
||||
(r) =>
|
||||
r.reason.id !== 'servers.busy.backup-creating' &&
|
||||
r.reason.id !== 'servers.busy.backup-restoring',
|
||||
),
|
||||
)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
interface BaseOperation {
|
||||
|
||||
@@ -335,6 +335,9 @@
|
||||
"content.page-layout.no-content-found": {
|
||||
"defaultMessage": "No content found."
|
||||
},
|
||||
"content.page-layout.please-wait": {
|
||||
"defaultMessage": "Please wait"
|
||||
},
|
||||
"content.page-layout.search-placeholder": {
|
||||
"defaultMessage": "Search {count} {contentType}..."
|
||||
},
|
||||
@@ -1814,15 +1817,57 @@
|
||||
"search.filter_type.shader_loader": {
|
||||
"defaultMessage": "Loader"
|
||||
},
|
||||
"servers.backups.admonition.backup-failed.description": {
|
||||
"defaultMessage": "Something went wrong while creating {backupName}. Please try again or contact support if the issue continues."
|
||||
},
|
||||
"servers.backups.admonition.backup-failed.title": {
|
||||
"defaultMessage": "Backup failed"
|
||||
},
|
||||
"servers.backups.admonition.backup-queued.description": {
|
||||
"defaultMessage": "{backupName} is queued and will start shortly."
|
||||
},
|
||||
"servers.backups.admonition.backup-queued.title": {
|
||||
"defaultMessage": "Backup queued"
|
||||
},
|
||||
"servers.backups.admonition.creating-backup.description": {
|
||||
"defaultMessage": "Saving world data and server configuration for {backupName}. This can take a few minutes."
|
||||
},
|
||||
"servers.backups.admonition.creating-backup.title": {
|
||||
"defaultMessage": "Creating backup"
|
||||
},
|
||||
"servers.backups.admonition.fallback-name": {
|
||||
"defaultMessage": "your backup"
|
||||
},
|
||||
"servers.backups.admonition.restore-failed.description": {
|
||||
"defaultMessage": "Something went wrong while restoring from {backupName}. Please try again or contact support if the issue continues."
|
||||
},
|
||||
"servers.backups.admonition.restore-failed.title": {
|
||||
"defaultMessage": "Restoring from backup failed"
|
||||
},
|
||||
"servers.backups.admonition.restore-queued.description": {
|
||||
"defaultMessage": "Restoring from {backupName} is queued and will start shortly."
|
||||
},
|
||||
"servers.backups.admonition.restore-queued.title": {
|
||||
"defaultMessage": "Restoring from backup queued"
|
||||
},
|
||||
"servers.backups.admonition.restore-successful.description": {
|
||||
"defaultMessage": "Your server has been restored to {backupName} and is ready to start."
|
||||
},
|
||||
"servers.backups.admonition.restore-successful.title": {
|
||||
"defaultMessage": "Restoring from backup successful"
|
||||
},
|
||||
"servers.backups.admonition.restoring-backup.description": {
|
||||
"defaultMessage": "Restoring your server from {backupName}. This may take a couple of minutes."
|
||||
},
|
||||
"servers.backups.admonition.restoring-backup.title": {
|
||||
"defaultMessage": "Restoring from backup"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
@@ -1832,21 +1877,12 @@
|
||||
"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.notice.dismiss": {
|
||||
"defaultMessage": "Dismiss"
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ export const AllTypes: Story = {
|
||||
<Admonition type="info" header="Info" body="This is an informational message." />
|
||||
<Admonition type="warning" header="Warning" body="This is a warning message." />
|
||||
<Admonition type="critical" header="Critical" body="This is a critical message." />
|
||||
<Admonition type="success" header="Success" body="This operation completed successfully." />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
@@ -36,3 +37,11 @@ export const WithHeader: Story = {
|
||||
body: 'Please read this carefully before proceeding.',
|
||||
},
|
||||
}
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
type: 'success',
|
||||
header: 'Operation Complete',
|
||||
body: 'Everything went smoothly.',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import BackupProgressAdmonition from '../../components/servers/backups/BackupProgressAdmonition.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Servers/BackupProgressAdmonition',
|
||||
component: BackupProgressAdmonition,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
},
|
||||
} satisfies Meta<typeof BackupProgressAdmonition>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const justNow = new Date().toISOString()
|
||||
const eightMinsAgo = new Date(Date.now() - 8 * 60 * 1000).toISOString()
|
||||
const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
export const AllStates: Story = {
|
||||
render: () => ({
|
||||
components: { BackupProgressAdmonition },
|
||||
setup() {
|
||||
const now = new Date().toISOString()
|
||||
const mins8 = new Date(Date.now() - 8 * 60 * 1000).toISOString()
|
||||
const hours5 = new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString()
|
||||
return { now, mins8, hours5 }
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div style="display: flex; flex-direction: column; gap: 1rem; max-width: 1020px;">
|
||||
<h3 style="margin: 0; color: var(--color-contrast);">Backup Creation</h3>
|
||||
<BackupProgressAdmonition type="create" state="ongoing" :progress="0" backup-name="World Backup 1" :created-at="now" />
|
||||
<BackupProgressAdmonition type="create" state="ongoing" :progress="0.33" backup-name="World Backup 1" :created-at="mins8" />
|
||||
<BackupProgressAdmonition type="create" state="failed" :progress="0" backup-name="World Backup 1" :created-at="hours5" />
|
||||
|
||||
<h3 style="margin: 1rem 0 0; color: var(--color-contrast);">Backup Restoration</h3>
|
||||
<BackupProgressAdmonition type="restore" state="ongoing" :progress="0" backup-name="World Backup 1" :created-at="now" />
|
||||
<BackupProgressAdmonition type="restore" state="ongoing" :progress="0.33" backup-name="World Backup 1" :created-at="mins8" />
|
||||
<BackupProgressAdmonition type="restore" state="done" :progress="1" backup-name="World Backup 1" :created-at="hours5" />
|
||||
<BackupProgressAdmonition type="restore" state="failed" :progress="0" backup-name="World Backup 1" :created-at="hours5" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const BackupQueued: Story = {
|
||||
args: {
|
||||
type: 'create',
|
||||
state: 'ongoing',
|
||||
progress: 0,
|
||||
backupName: 'World Backup 1',
|
||||
createdAt: justNow,
|
||||
},
|
||||
}
|
||||
|
||||
export const CreatingBackup: Story = {
|
||||
args: {
|
||||
type: 'create',
|
||||
state: 'ongoing',
|
||||
progress: 0.33,
|
||||
backupName: 'World Backup 1',
|
||||
createdAt: eightMinsAgo,
|
||||
},
|
||||
}
|
||||
|
||||
export const BackupFailed: Story = {
|
||||
args: {
|
||||
type: 'create',
|
||||
state: 'failed',
|
||||
progress: 0,
|
||||
backupName: 'World Backup 1',
|
||||
createdAt: fiveHoursAgo,
|
||||
},
|
||||
}
|
||||
|
||||
export const RestoreQueued: Story = {
|
||||
args: {
|
||||
type: 'restore',
|
||||
state: 'ongoing',
|
||||
progress: 0,
|
||||
backupName: 'World Backup 1',
|
||||
createdAt: justNow,
|
||||
},
|
||||
}
|
||||
|
||||
export const RestoringBackup: Story = {
|
||||
args: {
|
||||
type: 'restore',
|
||||
state: 'ongoing',
|
||||
progress: 0.33,
|
||||
backupName: 'World Backup 1',
|
||||
createdAt: eightMinsAgo,
|
||||
},
|
||||
}
|
||||
|
||||
export const RestoreSuccessful: Story = {
|
||||
args: {
|
||||
type: 'restore',
|
||||
state: 'done',
|
||||
progress: 1,
|
||||
backupName: 'World Backup 1',
|
||||
createdAt: fiveHoursAgo,
|
||||
},
|
||||
}
|
||||
|
||||
export const RestoreFailed: Story = {
|
||||
args: {
|
||||
type: 'restore',
|
||||
state: 'failed',
|
||||
progress: 0,
|
||||
backupName: 'World Backup 1',
|
||||
createdAt: fiveHoursAgo,
|
||||
},
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
BracesIcon,
|
||||
CalendarIcon,
|
||||
CardIcon,
|
||||
CheckCircleIcon,
|
||||
CurrencyIcon,
|
||||
DiscordIcon,
|
||||
FileArchiveIcon,
|
||||
@@ -66,6 +67,7 @@ export const SEVERITY_ICONS: Record<string, Component> = {
|
||||
warning: IssuesIcon,
|
||||
error: XCircleIcon,
|
||||
critical: XCircleIcon,
|
||||
success: CheckCircleIcon,
|
||||
}
|
||||
|
||||
export const PROJECT_STATUS_ICONS: Record<ProjectStatus, Component> = {
|
||||
|
||||
Reference in New Issue
Block a user