Files
AstralRinth/packages/ui/src/composables/server-manage-core-runtime.ts
T
Calum H. 6e7835fb35 feat: implement kryos upload sessions (#6145)
* feat: implement upload sessions

* fix: files not scoped

* feat: hide staging files folder and proper cancel feedback

* fix: lint
2026-05-21 16:49:48 +00:00

445 lines
13 KiB
TypeScript

import {
type Archon,
clearNodeAuthState,
setNodeAuthState,
type UploadState,
} from '@modrinth/api-client'
import type { Stats } from '@modrinth/utils'
import type { ComputedRef, Ref } from 'vue'
import { computed, ref } from 'vue'
import type { FileOperation } from '../layouts/shared/files-tab/types'
import { injectModrinthClient, provideModrinthServerContext } from '../providers'
import type { BusyReason, CancelUploadHandler } from '../providers/server-context'
import { defineMessage } from './i18n'
import { useModrinthServersConsole } from './server-console'
type ReadableRef<T> = Ref<T> | ComputedRef<T>
type SocketUnsubscriber = () => void
type ConnectSocketOptions = {
force?: boolean
extraSubscriptions?: (targetServerId: string) => SocketUnsubscriber[]
}
type UseServerManageCoreRuntimeOptions = {
serverId: ReadableRef<string>
worldId: ReadableRef<string | null>
server: ReadableRef<Archon.Servers.v0.Server | null | undefined>
isSyncingContent: ReadableRef<boolean>
extraBusyReasons?: ComputedRef<BusyReason[]>
setDisconnectedOnAuthIncorrect?: boolean
syncUptimeFromState?: boolean
incrementUptimeLocally?: boolean
eventGuard?: () => boolean
onStateEvent?: (data: Archon.Websocket.v0.WSStateEvent) => void
}
const createInitialStats = (): Stats => ({
current: {
cpu_percent: 0,
ram_usage_bytes: 0,
ram_total_bytes: 1,
storage_usage_bytes: 0,
storage_total_bytes: 0,
},
past: {
cpu_percent: 0,
ram_usage_bytes: 0,
ram_total_bytes: 1,
storage_usage_bytes: 0,
storage_total_bytes: 0,
},
graph: {
cpu: [],
ram: [],
},
})
const appendGraphData = (dataArray: number[], newValue: number): number[] => {
const updated = [...dataArray, newValue]
if (updated.length > 10) updated.shift()
return updated
}
const STALE_STATS_THRESHOLD_MS = 5000
const STALE_STATS_PUSH_INTERVAL_MS = 1000
const mapPowerStateFromStateEvent = (
data: Archon.Websocket.v0.WSStateEvent,
): Archon.Websocket.v0.PowerState => {
const powerMap: Record<Archon.Websocket.v0.FlattenedPowerState, Archon.Websocket.v0.PowerState> =
{
not_ready: 'stopped',
starting: 'starting',
running: 'running',
stopping: 'stopping',
idle:
data.was_oom || (data.exit_code != null && data.exit_code !== 0) ? 'crashed' : 'stopped',
}
return powerMap[data.power_variant]
}
export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOptions) {
const client = injectModrinthClient()
const modrinthServersConsole = useModrinthServersConsole()
const shouldProcessEvent = () => (options.eventGuard ? options.eventGuard() : true)
const isConnected = ref(false)
const isWsAuthIncorrect = ref(false)
const serverPowerState = ref<Archon.Websocket.v0.PowerState>('stopped')
const powerStateDetails = ref<{ oom_killed?: boolean; exit_code?: number }>()
const isServerRunning = computed(() => serverPowerState.value === 'running')
const stats = ref<Stats>(createInitialStats())
const uptimeSeconds = ref(0)
const fsAuth = ref<{ url: string; token: string } | null>(null)
const fsOps = ref<Archon.Websocket.v0.FilesystemOperation[]>([])
const fsQueuedOps = ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([])
const connectedSocketServerId = ref<string | null>(null)
const socketUnsubscribers = ref<SocketUnsubscriber[]>([])
const cpuData = ref<number[]>([])
const ramData = ref<number[]>([])
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
let staleStatsTimeoutId: ReturnType<typeof setTimeout> | null = null
let staleStatsIntervalId: ReturnType<typeof setInterval> | null = null
const busyReasons = computed<BusyReason[]>(() => {
const reasons: BusyReason[] = []
if (options.server.value?.status === 'installing') {
reasons.push({
reason: defineMessage({
id: 'servers.busy.installing',
defaultMessage: 'Server is installing',
}),
})
}
if (options.isSyncingContent.value) {
reasons.push({
reason: defineMessage({
id: 'servers.busy.syncing-content',
defaultMessage: 'Content sync in progress',
}),
})
}
if (options.extraBusyReasons) reasons.push(...options.extraBusyReasons.value)
return reasons
})
const stopUptimeTicker = () => {
if (uptimeIntervalId) {
clearInterval(uptimeIntervalId)
uptimeIntervalId = null
}
}
const startUptimeTicker = () => {
if (!options.incrementUptimeLocally || uptimeIntervalId) return
uptimeIntervalId = setInterval(() => {
uptimeSeconds.value += 1
}, 1000)
}
const updateStats = (currentStats: Stats['current']) => {
if (!shouldProcessEvent()) return
if (!isConnected.value) isConnected.value = true
cpuData.value = appendGraphData(cpuData.value, currentStats.cpu_percent)
ramData.value = appendGraphData(
ramData.value,
Math.floor((currentStats.ram_usage_bytes / currentStats.ram_total_bytes) * 100),
)
stats.value = {
current: currentStats,
past: { ...stats.value.current },
graph: {
cpu: cpuData.value,
ram: ramData.value,
},
}
}
const clearStaleStatsTimers = () => {
if (staleStatsTimeoutId) {
clearTimeout(staleStatsTimeoutId)
staleStatsTimeoutId = null
}
if (staleStatsIntervalId) {
clearInterval(staleStatsIntervalId)
staleStatsIntervalId = null
}
}
const pushZeroStats = () => {
if (!shouldProcessEvent()) return
cpuData.value = appendGraphData(cpuData.value, 0)
ramData.value = appendGraphData(ramData.value, 0)
stats.value = {
current: {
...stats.value.current,
cpu_percent: 0,
ram_usage_bytes: 0,
},
past: { ...stats.value.current },
graph: {
cpu: cpuData.value,
ram: ramData.value,
},
}
}
const armStaleStatsWatchdog = () => {
clearStaleStatsTimers()
staleStatsTimeoutId = setTimeout(() => {
pushZeroStats()
staleStatsIntervalId = setInterval(pushZeroStats, STALE_STATS_PUSH_INTERVAL_MS)
}, STALE_STATS_THRESHOLD_MS)
}
const updatePowerState = (
state: Archon.Websocket.v0.PowerState,
details?: { oom_killed?: boolean; exit_code?: number },
) => {
if (!shouldProcessEvent()) return
serverPowerState.value = state
powerStateDetails.value = state === 'crashed' ? details : undefined
if (state === 'stopped' || state === 'crashed') {
stopUptimeTicker()
uptimeSeconds.value = 0
}
}
const handleLog = (data: Archon.Websocket.v0.WSLogEvent) => {
if (!shouldProcessEvent()) return
modrinthServersConsole.recordWsEvent({ event: 'log', ...data })
modrinthServersConsole.addLegacyLog(data.message)
}
const handleLog4j = (data: Archon.Websocket.v0.WSLog4jEvent) => {
if (!shouldProcessEvent()) return
modrinthServersConsole.recordWsEvent({ event: 'log4j', ...data })
modrinthServersConsole.addLog4jEvent(data)
}
const handleStats = (data: Archon.Websocket.v0.WSStatsEvent) => {
armStaleStatsWatchdog()
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,
})
}
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 handleState = (data: Archon.Websocket.v0.WSStateEvent) => {
if (!shouldProcessEvent()) return
options.onStateEvent?.(data)
updatePowerState(mapPowerStateFromStateEvent(data), {
exit_code: data.exit_code ?? undefined,
oom_killed: data.was_oom,
})
if (options.syncUptimeFromState && data.uptime > 0) {
stopUptimeTicker()
uptimeSeconds.value = data.uptime
startUptimeTicker()
}
}
const handleUptime = (data: Archon.Websocket.v0.WSUptimeEvent) => {
if (!shouldProcessEvent()) return
stopUptimeTicker()
uptimeSeconds.value = data.uptime
startUptimeTicker()
}
const handleAuthIncorrect = () => {
if (!shouldProcessEvent()) return
isWsAuthIncorrect.value = true
if (options.setDisconnectedOnAuthIncorrect) {
isConnected.value = false
}
}
const handleAuthOk = () => {
if (!shouldProcessEvent()) return
isWsAuthIncorrect.value = false
isConnected.value = true
}
const clearSocketListeners = () => {
for (const unsub of socketUnsubscribers.value) unsub()
socketUnsubscribers.value = []
}
const disconnectSocket = (targetServerId?: string) => {
if (!targetServerId && !connectedSocketServerId.value) return
clearSocketListeners()
if (targetServerId) {
client.archon.sockets.disconnect(targetServerId)
}
stopUptimeTicker()
clearStaleStatsTimers()
connectedSocketServerId.value = null
isConnected.value = false
isWsAuthIncorrect.value = false
serverPowerState.value = 'stopped'
powerStateDetails.value = undefined
uptimeSeconds.value = 0
}
const connectSocket = async (
targetServerId: string,
connectOptions: ConnectSocketOptions = {},
): Promise<boolean> => {
if (
connectedSocketServerId.value === targetServerId &&
(isConnected.value || isWsAuthIncorrect.value)
) {
return true
}
disconnectSocket(connectedSocketServerId.value ?? undefined)
try {
const safeConnectOptions = connectOptions.force ? { force: true } : undefined
await client.archon.sockets.safeConnect(targetServerId, safeConnectOptions)
connectedSocketServerId.value = targetServerId
isConnected.value = true
isWsAuthIncorrect.value = false
modrinthServersConsole.clear()
modrinthServersConsole.beginInitialLogHydration()
const baseSubscriptions: SocketUnsubscriber[] = [
client.archon.sockets.on(targetServerId, 'log', handleLog),
client.archon.sockets.on(targetServerId, 'log4j', handleLog4j),
client.archon.sockets.on(targetServerId, 'stats', handleStats),
client.archon.sockets.on(targetServerId, 'state', handleState),
client.archon.sockets.on(targetServerId, 'power-state', handlePowerState),
client.archon.sockets.on(targetServerId, 'uptime', handleUptime),
client.archon.sockets.on(targetServerId, 'auth-incorrect', handleAuthIncorrect),
client.archon.sockets.on(targetServerId, 'auth-ok', handleAuthOk),
]
const extraSubscriptions = connectOptions.extraSubscriptions?.(targetServerId) ?? []
socketUnsubscribers.value = [...baseSubscriptions, ...extraSubscriptions]
return true
} catch (error) {
console.error('[hosting/manage] Failed to connect server socket:', error)
isConnected.value = false
return false
}
}
const uploadState = ref<UploadState>({
isUploading: false,
currentFileName: null,
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes: 0,
completedFiles: 0,
totalFiles: 0,
})
const cancelUpload = ref<CancelUploadHandler | null>(null)
type QueuedOpWithState = Archon.Websocket.v0.QueuedFilesystemOp & { state: 'queued' }
const dismissedOpIds = ref<Set<string>>(new Set())
const activeOperations = computed<FileOperation[]>(() => [
...fsQueuedOps.value.map((x) => ({ ...x, state: 'queued' }) satisfies QueuedOpWithState),
...(fsOps.value.filter((op) => !op.id || !dismissedOpIds.value.has(op.id)) as FileOperation[]),
])
async function dismissOperation(opId: string, action: 'dismiss' | 'cancel') {
if (action === 'dismiss') {
dismissedOpIds.value = new Set([...dismissedOpIds.value, opId])
}
try {
await client.kyros.files_v0.modifyOperation(opId, action)
} catch (error) {
if (action === 'dismiss') return
console.error(`Failed to ${action} operation:`, error)
}
}
const refreshFsAuth = async () => {
if (!options.serverId.value) {
fsAuth.value = null
return
}
fsAuth.value = await client.archon.servers_v0.getFilesystemAuth(options.serverId.value)
}
provideModrinthServerContext({
get serverId() {
return options.serverId.value
},
worldId: options.worldId as Ref<string | null>,
server: options.server as Ref<Archon.Servers.v0.Server>,
isConnected,
isWsAuthIncorrect,
powerState: serverPowerState,
powerStateDetails,
isServerRunning,
stats,
uptimeSeconds,
isSyncingContent: options.isSyncingContent as Ref<boolean>,
busyReasons,
fsAuth,
fsOps,
fsQueuedOps,
refreshFsAuth,
uploadState,
cancelUpload,
activeOperations,
dismissOperation,
})
setNodeAuthState(() => fsAuth.value, refreshFsAuth)
const cleanupCoreRuntime = (targetServerId?: string) => {
disconnectSocket(targetServerId ?? connectedSocketServerId.value ?? undefined)
clearNodeAuthState()
}
return {
activeOperations,
busyReasons,
cancelUpload,
cleanupCoreRuntime,
connectSocket,
connectedSocketServerId,
cpuData,
disconnectSocket,
dismissOperation,
fsAuth,
fsOps,
fsQueuedOps,
isConnected,
isServerRunning,
isWsAuthIncorrect,
powerStateDetails,
ramData,
refreshFsAuth,
serverPowerState,
stats,
uptimeSeconds,
uploadState,
}
}