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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user