feat: backups page cleanup before worlds (#5844)

* feat: card alignment + fix modals

* feat: change admon title in restore alert modal

* fix: lint

* feat: backups queue api into api-client

* feat: impl backup queue api endpoints into frontend

* feat: ack fix

* feat: bulk actions

* feat: bulk delete impl

* fix: lint

* fix: align error states

* fix: transition group

* feat: ready for qa

* fix: lint

* feat: qa

* feat: stacked admonitions component

* fix: issues with stacking

* feat: hook up admonition stacking + fix app csp for staging kyros nodes

* fix: logs.vue

* qa: close stack on admonitions click

* fix: all problems with stacked admonitions

* qa: admonition cleanup and copy overhaul draft

* fix: qa issues padding

* fix: padding bug

* feat: qa

* fix: intercom in app csp bug

* fix: positioning intercom

* feat: loading overlay on top of console + admon consistency changes

* feat: scroll indicator fade in backup delete modal + admon timestamp fix

* feat: move action bar behind modal

* fix: lint + i18n

* fix: server ping spam on filter (cache but clear on unmount)

* fix: 1 admon fade in flicker issue

* chore: temp staging undo

* qa: changes

* fix: lint

* chore: revert staging to use staging

* fix: scoping
This commit is contained in:
Calum H.
2026-04-27 20:03:48 +01:00
committed by GitHub
parent 85ae1f2074
commit 620894aecb
79 changed files with 4640 additions and 1656 deletions

View File

@@ -9,6 +9,7 @@ export * from './i18n-debug'
export * from './page-leave-safety'
export * from './scroll-indicator'
export * from './server-backup'
export * from './server-backups-queue'
export * from './server-console'
export * from './server-manage-core-runtime'
export * from './sticky-observer'

View File

@@ -20,7 +20,7 @@ export function useScrollIndicator(
containerRef: Ref<HTMLElement | null>,
options: ScrollIndicatorOptions = {},
): ScrollIndicator {
const { watchContent = true, debounceMs = 10, tolerance = 1, debug = false } = options
const { watchContent = true, debounceMs = 0, tolerance = 1, debug = false } = options
const showTopFade = ref(false)
const showBottomFade = ref(false)

View File

@@ -0,0 +1,133 @@
import type { Archon } from '@modrinth/api-client'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, reactive, type Ref } from 'vue'
import { type BusyReason, injectModrinthClient } from '#ui/providers'
import { defineMessage } from './i18n'
type ProgressKey = `${string}:${'create' | 'restore'}`
export function useServerBackupsQueue(serverId: Ref<string>, worldId: Ref<string | null>) {
const client = injectModrinthClient()
const queryClient = useQueryClient()
const queryKey = computed(() => ['backups', 'queue', serverId.value] as const)
const progressOverlay = reactive(new Map<ProgressKey, number>())
const lastSeenState = new Map<ProgressKey, Archon.Websocket.v0.BackupState>()
const query = useQuery({
queryKey,
queryFn: () => client.archon.backups_queue_v1.list(serverId.value, worldId.value!),
enabled: computed(() => !!worldId.value),
refetchInterval: (q) => {
const data = q.state.data as Archon.BackupsQueue.v1.BackupsQueueResponse | undefined
return data?.active_operations?.length ? 3000 : false
},
})
const data = computed(() => query.data.value)
const activeOperations = computed(() => data.value?.active_operations ?? [])
const backups = computed(() =>
[...(data.value?.backups ?? [])].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
),
)
const activeOperationByBackupId = computed(() => {
const map = new Map<string, Archon.BackupsQueue.v1.ActiveOperation>()
for (const op of activeOperations.value) map.set(op.backup_id, op)
return map
})
const backupById = computed(() => {
const map = new Map<string, Archon.BackupsQueue.v1.BackupQueueBackup>()
for (const backup of backups.value) map.set(backup.id, backup)
return map
})
const hasActiveCreate = computed(() =>
activeOperations.value.some((o) => o.operation_type === 'create' && !o.has_parent),
)
const hasActiveRestore = computed(() =>
activeOperations.value.some((o) => o.operation_type === 'restore'),
)
const hasRunningCreate = computed(() =>
activeOperations.value.some(
(o) =>
o.operation_type === 'create' &&
!o.has_parent &&
backupById.value.get(o.backup_id)?.status === 'in_progress',
),
)
const hasRunningRestore = computed(() =>
activeOperations.value.some(
(o) =>
o.operation_type === 'restore' &&
backupById.value.get(o.backup_id)?.status === 'in_progress',
),
)
function handleWsBackupProgress(evt: Archon.Websocket.v0.WSBackupProgressEvent) {
if (evt.task === 'file') return
const key = `${evt.id}:${evt.task}` as ProgressKey
if (evt.state === 'ongoing') {
progressOverlay.set(key, evt.progress)
} else {
progressOverlay.delete(key)
}
const prev = lastSeenState.get(key)
if (prev !== evt.state) {
lastSeenState.set(key, evt.state)
queryClient.invalidateQueries({ queryKey: queryKey.value })
}
}
function progressFor(backupId: string, kind: 'create' | 'restore'): number | undefined {
return progressOverlay.get(`${backupId}:${kind}`)
}
const busyReasons = computed<BusyReason[]>(() => {
const reasons: BusyReason[] = []
if (hasRunningCreate.value) {
reasons.push({
reason: defineMessage({
id: 'servers.busy.backup-creating',
defaultMessage: 'Backup creation in progress',
}),
})
}
if (hasRunningRestore.value) {
reasons.push({
reason: defineMessage({
id: 'servers.busy.backup-restoring',
defaultMessage: 'Backup restore in progress',
}),
})
}
return reasons
})
async function invalidate() {
await queryClient.invalidateQueries({ queryKey: queryKey.value })
}
return {
query,
queryKey,
data,
activeOperations,
activeOperationByBackupId,
backups,
hasActiveCreate,
hasActiveRestore,
hasRunningCreate,
hasRunningRestore,
progressFor,
handleWsBackupProgress,
busyReasons,
invalidate,
}
}

View File

@@ -1,5 +1,5 @@
import { createGlobalState } from '@vueuse/core'
import { type Ref, shallowRef, triggerRef } from 'vue'
import { type Ref, ref, shallowRef, triggerRef } from 'vue'
import { detectLogLevel } from '../layouts/shared/console/composables/log-level'
import type { Log4jEvent, LogLevel, LogLine } from '../layouts/shared/console/types'
@@ -52,6 +52,8 @@ function groupContinuations(lines: LogLine[]): LogLine[] {
const batchTimeout = 300
const initialBatchSize = 256
const initialHydrationQuietMs = 700
const initialHydrationMaxMs = 2000
const LogLevelCode = {
None: 0,
@@ -224,10 +226,43 @@ export function createConsoleState() {
let lineBuffer: LogLine[] = []
let batchTimer: NodeJS.Timeout | null = null
let initialHydrationQuietTimer: ReturnType<typeof setTimeout> | null = null
let initialHydrationMaxTimer: ReturnType<typeof setTimeout> | null = null
const isInitialLogHydrating = ref(false)
let wrapCount = 0
let lastFlushMs = 0
const clearInitialHydrationTimers = (): void => {
if (initialHydrationQuietTimer) {
clearTimeout(initialHydrationQuietTimer)
initialHydrationQuietTimer = null
}
if (initialHydrationMaxTimer) {
clearTimeout(initialHydrationMaxTimer)
initialHydrationMaxTimer = null
}
}
const settleInitialLogHydration = (): void => {
if (!isInitialLogHydrating.value) return
clearInitialHydrationTimers()
flushBuffer()
isInitialLogHydrating.value = false
}
const armInitialHydrationQuietTimer = (): void => {
if (initialHydrationQuietTimer) clearTimeout(initialHydrationQuietTimer)
initialHydrationQuietTimer = setTimeout(settleInitialLogHydration, initialHydrationQuietMs)
}
const beginInitialLogHydration = (): void => {
clearInitialHydrationTimers()
isInitialLogHydrating.value = true
armInitialHydrationQuietTimer()
initialHydrationMaxTimer = setTimeout(settleInitialLogHydration, initialHydrationMaxMs)
}
const flushBuffer = (): void => {
if (lineBuffer.length === 0) return
@@ -269,6 +304,12 @@ export function createConsoleState() {
}
const addLines = (lines: LogLine[]): void => {
if (isInitialLogHydrating.value) {
lineBuffer.push(...lines)
armInitialHydrationQuietTimer()
return
}
if (output.value.length === 0 && lines.length >= initialBatchSize) {
lineBuffer = lines
flushBuffer()
@@ -326,6 +367,8 @@ export function createConsoleState() {
lineBuffer = []
wsEventHistory.length = 0
wrapCount = 0
isInitialLogHydrating.value = false
clearInitialHydrationTimers()
if (batchTimer) {
clearTimeout(batchTimer)
batchTimer = null
@@ -359,6 +402,8 @@ export function createConsoleState() {
return {
output,
isInitialLogHydrating,
beginInitialLogHydration,
addLines,
addLog4jEvent,
addLegacyLog,

View File

@@ -6,7 +6,7 @@ import {
} from '@modrinth/api-client'
import type { Stats } from '@modrinth/utils'
import type { ComputedRef, Ref } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import type { FileOperation } from '../layouts/shared/files-tab/types'
import { injectModrinthClient, provideModrinthServerContext } from '../providers'
@@ -27,8 +27,7 @@ type UseServerManageCoreRuntimeOptions = {
worldId: ReadableRef<string | null>
server: ReadableRef<Archon.Servers.v0.Server | null | undefined>
isSyncingContent: ReadableRef<boolean>
markBackupCancelled?: (backupId: string) => void
includeBackupBusyReasons?: boolean
extraBusyReasons?: ComputedRef<BusyReason[]>
setDisconnectedOnAuthIncorrect?: boolean
syncUptimeFromState?: boolean
incrementUptimeLocally?: boolean
@@ -94,7 +93,6 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
const isServerRunning = computed(() => serverPowerState.value === 'running')
const stats = ref<Stats>(createInitialStats())
const uptimeSeconds = ref(0)
const backupsState = reactive(new Map())
const fsAuth = ref<{ url: string; token: string } | null>(null)
const fsOps = ref<Archon.Websocket.v0.FilesystemOperation[]>([])
const fsQueuedOps = ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([])
@@ -107,12 +105,6 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
let staleStatsTimeoutId: ReturnType<typeof setTimeout> | null = null
let staleStatsIntervalId: ReturnType<typeof setInterval> | null = null
const markBackupCancelled =
options.markBackupCancelled ??
((backupId: string) => {
backupsState.delete(backupId)
})
const busyReasons = computed<BusyReason[]>(() => {
const reasons: BusyReason[] = []
if (options.server.value?.status === 'installing') {
@@ -131,28 +123,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
}),
})
}
if (options.includeBackupBusyReasons) {
for (const entry of backupsState.values()) {
if (entry.create?.state === 'ongoing') {
reasons.push({
reason: defineMessage({
id: 'servers.busy.backup-creating',
defaultMessage: 'Backup creation in progress',
}),
})
break
}
if (entry.restore?.state === 'ongoing') {
reasons.push({
reason: defineMessage({
id: 'servers.busy.backup-restoring',
defaultMessage: 'Backup restore in progress',
}),
})
break
}
}
}
if (options.extraBusyReasons) reasons.push(...options.extraBusyReasons.value)
return reasons
})
@@ -353,6 +324,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
isWsAuthIncorrect.value = false
modrinthServersConsole.clear()
modrinthServersConsole.beginInitialLogHydration()
const baseSubscriptions: SocketUnsubscriber[] = [
client.archon.sockets.on(targetServerId, 'log', handleLog),
@@ -405,20 +377,6 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
}
}
watch(
() => fsOps.value,
(newOps) => {
for (const op of newOps) {
if (op.state === 'done' && op.id && !dismissedOpIds.value.has(op.id)) {
setTimeout(() => {
dismissOperation(op.id!, 'dismiss')
}, 3000)
}
}
},
{ deep: true },
)
const refreshFsAuth = async () => {
if (!options.serverId.value) {
fsAuth.value = null
@@ -440,8 +398,6 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
isServerRunning,
stats,
uptimeSeconds,
backupsState,
markBackupCancelled,
isSyncingContent: options.isSyncingContent as Ref<boolean>,
busyReasons,
fsAuth,
@@ -463,7 +419,6 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
return {
activeOperations,
backupsState,
busyReasons,
cancelUpload,
cleanupCoreRuntime,