feat: sync individual content installation states on panel (#6909)

* feat: sync individual content installation states on panel

* feat: improve handling

* fix: lint

* fix: ws connection duplication + disconnecting during browse

* fix: sse feats

* fix: qa

* fix: qa

* fix: prepr

* fix: bug

* fix: lint
This commit is contained in:
Calum H.
2026-08-14 16:38:36 +00:00
committed by GitHub
parent 99683f570a
commit 9628b4c269
54 changed files with 2006 additions and 2429 deletions
+2
View File
@@ -14,7 +14,9 @@ export * from './scroll-indicator'
export * from './server-backup'
export * from './server-backups-queue'
export * from './server-console'
export * from './server-context-runtime'
export * from './server-manage-core-runtime'
export * from './server-panel-sync'
export * from './server-permissions'
export { applyEarsMod, removeEarsMod } from './skin-rendering/use-ears-mod-features'
export * from './sticky-observer'
@@ -0,0 +1,350 @@
import type { AbstractModrinthClient, Archon } from '@modrinth/api-client'
import type { ComputedRef, Ref } from 'vue'
import { onUnmounted, ref, watch } from 'vue'
import { injectModrinthClient } from '../providers'
type ReadableRef<T> = Ref<T> | ComputedRef<T>
type RuntimeUnsubscriber = () => void
type RuntimeReadyWaiter = {
resolve: () => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
type ServerContextRuntime = {
client: AbstractModrinthClient
serverId: string
leases: number
socketLeases: number
syncLeases: number
releaseTimer: ReturnType<typeof setTimeout> | null
socketReleaseTimer: ReturnType<typeof setTimeout> | null
syncReleaseTimer: ReturnType<typeof setTimeout> | null
connectPromise: Promise<void> | null
socketUnsubscribers: RuntimeUnsubscriber[]
installProgressItems: Ref<Archon.Websocket.v0.InstallProgressItem[]>
isSocketAuthenticated: Ref<boolean>
isSocketAuthIncorrect: Ref<boolean>
hasAuthoritativeInstallProgress: Ref<boolean>
readyWaiters: Set<RuntimeReadyWaiter>
destroyed: boolean
}
export type ServerContextRuntimeLease = {
serverId: string
installProgressItems: Ref<Archon.Websocket.v0.InstallProgressItem[]>
isSocketAuthenticated: Ref<boolean>
isSocketAuthIncorrect: Ref<boolean>
hasAuthoritativeInstallProgress: Ref<boolean>
waitUntilReady: () => Promise<void>
release: () => void
}
type RetainServerContextRuntimeOptions = {
connect?: boolean
socket?: boolean
sync?: boolean
}
const runtimeReleaseDelay = 1000
const authoritativeReadinessTimeout = 30000
const runtimesByClient = new WeakMap<AbstractModrinthClient, Map<string, ServerContextRuntime>>()
function getClientRuntimes(client: AbstractModrinthClient) {
let runtimes = runtimesByClient.get(client)
if (!runtimes) {
runtimes = new Map()
runtimesByClient.set(client, runtimes)
}
return runtimes
}
function isRuntimeReady(runtime: ServerContextRuntime) {
return runtime.isSocketAuthenticated.value && runtime.hasAuthoritativeInstallProgress.value
}
function resolveReadyWaiters(runtime: ServerContextRuntime) {
if (!isRuntimeReady(runtime)) return
for (const waiter of runtime.readyWaiters) {
clearTimeout(waiter.timeout)
waiter.resolve()
}
runtime.readyWaiters.clear()
}
function createServerContextRuntime(
client: AbstractModrinthClient,
serverId: string,
): ServerContextRuntime {
const runtime: ServerContextRuntime = {
client,
serverId,
leases: 0,
socketLeases: 0,
syncLeases: 0,
releaseTimer: null,
socketReleaseTimer: null,
syncReleaseTimer: null,
connectPromise: null,
socketUnsubscribers: [],
installProgressItems: ref([]),
isSocketAuthenticated: ref(false),
isSocketAuthIncorrect: ref(false),
hasAuthoritativeInstallProgress: ref(false),
readyWaiters: new Set(),
destroyed: false,
}
return runtime
}
function attachRuntimeSocketListeners(runtime: ServerContextRuntime) {
if (runtime.socketUnsubscribers.length > 0) return
runtime.socketUnsubscribers = [
runtime.client.archon.sockets.on(runtime.serverId, 'auth-ok', () => {
runtime.isSocketAuthenticated.value = true
runtime.isSocketAuthIncorrect.value = false
runtime.hasAuthoritativeInstallProgress.value = false
}),
runtime.client.archon.sockets.on(runtime.serverId, 'auth-incorrect', () => {
runtime.isSocketAuthenticated.value = false
runtime.isSocketAuthIncorrect.value = true
runtime.hasAuthoritativeInstallProgress.value = false
}),
runtime.client.archon.sockets.on(runtime.serverId, 'install-progress', (event) => {
runtime.installProgressItems.value = event.items
runtime.hasAuthoritativeInstallProgress.value = true
resolveReadyWaiters(runtime)
}),
]
}
function disconnectRuntimeSocket(runtime: ServerContextRuntime) {
for (const unsubscribe of runtime.socketUnsubscribers) unsubscribe()
runtime.socketUnsubscribers = []
runtime.client.archon.sockets.disconnect(runtime.serverId)
runtime.connectPromise = null
runtime.isSocketAuthenticated.value = false
runtime.isSocketAuthIncorrect.value = false
runtime.hasAuthoritativeInstallProgress.value = false
for (const waiter of runtime.readyWaiters) {
clearTimeout(waiter.timeout)
waiter.reject(new Error(`Node socket for server ${runtime.serverId} was released`))
}
runtime.readyWaiters.clear()
}
function disconnectRuntimeSync(runtime: ServerContextRuntime) {
runtime.client.archon.sync.disconnect(runtime.serverId)
}
async function ensureRuntimeConnections(
runtime: ServerContextRuntime,
options: RetainServerContextRuntimeOptions = {},
) {
if (runtime.destroyed) {
throw new Error(`Server context runtime for ${runtime.serverId} has been released`)
}
const shouldConnectSocket = options.socket !== false
const shouldConnectSync = options.sync !== false
const socketStatus = runtime.client.archon.sockets.getStatus(runtime.serverId)
if (shouldConnectSocket && !socketStatus?.connected) {
attachRuntimeSocketListeners(runtime)
runtime.isSocketAuthenticated.value = false
runtime.hasAuthoritativeInstallProgress.value = false
}
if (shouldConnectSync) {
void runtime.client.archon.sync
.safeConnectServer(runtime.serverId, { intent: 'all' })
.catch((error) => {
console.warn(
`[server-context-runtime] Failed to connect sync stream for ${runtime.serverId}:`,
error,
)
})
}
if (shouldConnectSocket && !runtime.connectPromise) {
const connectPromise = runtime.client.archon.sockets
.safeConnect(runtime.serverId)
.then(() => {
runtime.isSocketAuthenticated.value = true
})
.finally(() => {
if (runtime.connectPromise === connectPromise) {
runtime.connectPromise = null
}
})
runtime.connectPromise = connectPromise
}
if (runtime.connectPromise) await runtime.connectPromise
}
async function waitUntilRuntimeReady(runtime: ServerContextRuntime) {
await ensureRuntimeConnections(runtime)
if (isRuntimeReady(runtime)) return
await new Promise<void>((resolve, reject) => {
const waiter: RuntimeReadyWaiter = {
resolve,
reject,
timeout: setTimeout(() => {
runtime.readyWaiters.delete(waiter)
reject(
new Error(
`Timed out waiting for authoritative install progress for server ${runtime.serverId}`,
),
)
}, authoritativeReadinessTimeout),
}
runtime.readyWaiters.add(waiter)
resolveReadyWaiters(runtime)
})
}
function destroyRuntime(runtime: ServerContextRuntime) {
if (runtime.destroyed || runtime.leases > 0) return
runtime.destroyed = true
if (runtime.socketReleaseTimer) clearTimeout(runtime.socketReleaseTimer)
if (runtime.syncReleaseTimer) clearTimeout(runtime.syncReleaseTimer)
disconnectRuntimeSocket(runtime)
disconnectRuntimeSync(runtime)
getClientRuntimes(runtime.client).delete(runtime.serverId)
}
export function retainServerContextRuntime(
client: AbstractModrinthClient,
serverId: string,
options: RetainServerContextRuntimeOptions = {},
): ServerContextRuntimeLease {
const runtimes = getClientRuntimes(client)
let runtime = runtimes.get(serverId)
if (!runtime) {
runtime = createServerContextRuntime(client, serverId)
runtimes.set(serverId, runtime)
}
if (runtime.releaseTimer) {
clearTimeout(runtime.releaseTimer)
runtime.releaseTimer = null
}
const retainSocket = options.socket !== false
const retainSync = options.sync !== false
if (retainSocket) {
if (runtime.socketReleaseTimer) {
clearTimeout(runtime.socketReleaseTimer)
runtime.socketReleaseTimer = null
}
attachRuntimeSocketListeners(runtime)
runtime.socketLeases += 1
}
if (retainSync) {
if (runtime.syncReleaseTimer) {
clearTimeout(runtime.syncReleaseTimer)
runtime.syncReleaseTimer = null
}
runtime.syncLeases += 1
}
runtime.leases += 1
if (options.connect !== false) {
void ensureRuntimeConnections(runtime, options).catch((error) => {
if (runtime && runtime.leases > 0) {
console.warn(
`[server-context-runtime] Failed to connect node socket for ${serverId}:`,
error,
)
}
})
}
let released = false
return {
serverId,
installProgressItems: runtime.installProgressItems,
isSocketAuthenticated: runtime.isSocketAuthenticated,
isSocketAuthIncorrect: runtime.isSocketAuthIncorrect,
hasAuthoritativeInstallProgress: runtime.hasAuthoritativeInstallProgress,
waitUntilReady: () => waitUntilRuntimeReady(runtime),
release: () => {
if (released) return
released = true
runtime.leases = Math.max(0, runtime.leases - 1)
if (retainSocket) {
runtime.socketLeases = Math.max(0, runtime.socketLeases - 1)
}
if (retainSync) {
runtime.syncLeases = Math.max(0, runtime.syncLeases - 1)
}
if (runtime.leases === 0) {
if (runtime.socketReleaseTimer) clearTimeout(runtime.socketReleaseTimer)
if (runtime.syncReleaseTimer) clearTimeout(runtime.syncReleaseTimer)
runtime.socketReleaseTimer = null
runtime.syncReleaseTimer = null
runtime.releaseTimer = setTimeout(() => {
runtime.releaseTimer = null
destroyRuntime(runtime)
}, runtimeReleaseDelay)
return
}
if (retainSocket && runtime.socketLeases === 0) {
runtime.socketReleaseTimer = setTimeout(() => {
runtime.socketReleaseTimer = null
if (runtime.socketLeases === 0) disconnectRuntimeSocket(runtime)
}, runtimeReleaseDelay)
}
if (retainSync && runtime.syncLeases === 0) {
runtime.syncReleaseTimer = setTimeout(() => {
runtime.syncReleaseTimer = null
if (runtime.syncLeases === 0) disconnectRuntimeSync(runtime)
}, runtimeReleaseDelay)
}
},
}
}
export function useServerContextRuntime(serverId: ReadableRef<string | null>) {
const client = injectModrinthClient()
let lease: ServerContextRuntimeLease | null = null
const stop = watch(
() => serverId.value,
(nextServerId) => {
lease?.release()
lease = null
if (typeof window !== 'undefined' && nextServerId) {
lease = retainServerContextRuntime(client, nextServerId)
}
},
{ immediate: true },
)
onUnmounted(() => {
stop()
lease?.release()
lease = null
})
}
export async function waitForServerContextRuntimeReady(
client: AbstractModrinthClient,
serverId: string,
) {
const lease = retainServerContextRuntime(client, serverId)
try {
await lease.waitUntilReady()
} finally {
lease.release()
}
}
@@ -0,0 +1,342 @@
import type { Archon } from '@modrinth/api-client'
import type { ComputedRef, Ref } from 'vue'
import { computed, ref, watch } from 'vue'
type ReadableRef<T> = Ref<T> | ComputedRef<T>
export type ServerInstallationKey =
| Exclude<Archon.Websocket.v0.InstallProgressKey, { type: 'file' }>
| { type: 'unknown' }
export type ServerInstallationState = {
id: string
key: ServerInstallationKey
status: 'pending' | 'installing' | 'complete' | 'failed'
progress: number | null
error: string | null
source: 'optimistic' | 'websocket' | 'server'
}
type OptimisticInstallation = {
id: string
key: ServerInstallationKey
startRevision: number
}
type UseServerInstallationTrackerOptions = {
worldId: ReadableRef<string | null>
server: ReadableRef<Archon.Servers.v0.Server | null | undefined>
content?: ReadableRef<Archon.Content.v1.Addons | null | undefined>
}
type ServerInstallationPlatform = Extract<ServerInstallationKey, { type: 'platform' }>['platform']
function installationKeyId(key: ServerInstallationKey) {
switch (key.type) {
case 'platform':
return `platform:${key.platform}:${key.platform_version}:${key.game_version}`
case 'modrinth_modpack':
return `modrinth-modpack:${key.project_id}:${key.version_id}`
case 'local_modpack':
return `local-modpack:${key.filename}`
case 'unknown':
return 'unknown'
}
}
function itemStatus(
item: Archon.Websocket.v0.InstallProgressItem,
): ServerInstallationState['status'] {
if (item.error != null) return 'failed'
if (item.progress === 100) return 'complete'
return 'installing'
}
function contentPlatform(modloader: string | null): ServerInstallationPlatform | null {
const platform = modloader === 'neo_forge' ? 'neoforge' : modloader
switch (platform) {
case 'forge':
case 'neoforge':
case 'fabric':
case 'quilt':
case 'paper':
case 'purpur':
case 'vanilla':
return platform
default:
return null
}
}
function contentInstallationKey(content: Archon.Content.v1.Addons): ServerInstallationKey {
if (content.installing === 'modpack' || (content.error && content.modpack)) {
const spec = content.modpack?.spec
if (spec?.platform === 'modrinth') {
return {
type: 'modrinth_modpack',
project_id: spec.project_id,
version_id: spec.version_id,
}
}
if (spec?.platform === 'local_file') {
return {
type: 'local_modpack',
filename: spec.filename,
}
}
return { type: 'unknown' }
}
const platform = contentPlatform(content.modloader)
if (platform && content.game_version) {
return {
type: 'platform',
platform,
platform_version: content.modloader_version ?? '',
game_version: content.game_version,
}
}
return { type: 'unknown' }
}
function addonFailureId(addon: Archon.Content.v1.Addon) {
return `addon:${addon.id}:${addon.filename}`
}
function addonFailureError(addon: Archon.Content.v1.Addon) {
return typeof addon.status === 'object' ? addon.status.failed.error : null
}
export function useServerInstallationTracker(options: UseServerInstallationTrackerOptions) {
const installProgressItems = ref<Archon.Websocket.v0.InstallProgressItem[]>([])
const optimisticInstallation = ref<OptimisticInstallation | null>(null)
const receivedProgressSnapshot = ref(false)
const snapshotRevision = ref(0)
const seenActiveIds = ref(new Set<string>())
const dismissedIds = ref(new Set<string>())
let unknownInstallationId = 0
const currentWorldItems = computed(() =>
installProgressItems.value.filter((item) => item.world_id === options.worldId.value),
)
const websocketInstallation = computed<ServerInstallationState | null>(() => {
const optimistic = optimisticInstallation.value
const candidates = currentWorldItems.value.filter(
(item) => item.key.type !== 'file' && !dismissedIds.value.has(installationKeyId(item.key)),
)
for (const item of candidates) {
if (item.key.type === 'file') continue
const id = installationKeyId(item.key)
const status = itemStatus(item)
if (status === 'complete') {
if (
optimistic &&
snapshotRevision.value <= optimistic.startRevision &&
!seenActiveIds.value.has(id)
) {
continue
}
if (
!optimistic &&
!seenActiveIds.value.has(id) &&
options.server.value?.status !== 'installing'
) {
continue
}
}
return {
id,
key: item.key,
status,
progress: item.progress,
error: item.error,
source: 'websocket',
}
}
return null
})
const persistedAddonFailure = computed<ServerInstallationState | null>(() => {
const addon = options.content?.value?.addons?.find((addon) => {
const id = addonFailureId(addon)
return addonFailureError(addon) != null && !dismissedIds.value.has(id)
})
if (!addon) return null
return {
id: addonFailureId(addon),
key: { type: 'unknown' },
status: 'failed',
progress: null,
error: addonFailureError(addon),
source: 'server',
}
})
const persistedInstallation = computed<ServerInstallationState | null>(() => {
const content = options.content?.value
if (!content || (!content.installing && !content.error)) return null
const key = contentInstallationKey(content)
const id = installationKeyId(key)
if (dismissedIds.value.has(id)) return null
return {
id,
key,
status: content.error ? 'failed' : 'installing',
progress: null,
error: content.error?.message ?? null,
source: 'server',
}
})
watch(
() => options.content?.value,
(content) => {
if (!content) return
if (content.error) {
optimisticInstallation.value = null
return
}
if (!content.installing) return
const id = installationKeyId(contentInstallationKey(content))
if (dismissedIds.value.has(id)) {
dismissedIds.value = new Set([...dismissedIds.value].filter((item) => item !== id))
}
optimisticInstallation.value = null
},
)
watch(
() => options.content?.value?.addons,
(addons) => {
const currentFailureIds = new Set(
(addons ?? []).filter((addon) => addonFailureError(addon) != null).map(addonFailureId),
)
const nextDismissedIds = new Set(
[...dismissedIds.value].filter(
(id) => !id.startsWith('addon:') || currentFailureIds.has(id),
),
)
if (nextDismissedIds.size !== dismissedIds.value.size) {
dismissedIds.value = nextDismissedIds
}
},
)
const installation = computed<ServerInstallationState | null>(() => {
if (websocketInstallation.value) return websocketInstallation.value
const optimistic = optimisticInstallation.value
if (optimistic && !dismissedIds.value.has(optimistic.id)) {
return {
id: optimistic.id,
key: optimistic.key,
status: 'pending',
progress: null,
error: null,
source: 'optimistic',
}
}
if (persistedInstallation.value) return persistedInstallation.value
if (persistedAddonFailure.value) return persistedAddonFailure.value
if (options.content?.value?.error) return null
if (options.server.value?.status !== 'installing' || receivedProgressSnapshot.value) return null
return {
id: `unknown:${unknownInstallationId}`,
key: { type: 'unknown' },
status: 'installing',
progress: null,
error: null,
source: 'server',
}
})
const isBlocking = computed(
() => installation.value?.status === 'pending' || installation.value?.status === 'installing',
)
function handleProgress(items: Archon.Websocket.v0.InstallProgressItem[]) {
snapshotRevision.value += 1
receivedProgressSnapshot.value = true
installProgressItems.value = items
const optimistic = optimisticInstallation.value
const isPostOptimisticSnapshot =
optimistic !== null && snapshotRevision.value > optimistic.startRevision
const nextSeenActiveIds = new Set(seenActiveIds.value)
const nextDismissedIds = new Set(dismissedIds.value)
let hasAuthoritativeInstallation = false
for (const item of items) {
if (item.world_id !== options.worldId.value || item.key.type === 'file') continue
hasAuthoritativeInstallation = true
const id = installationKeyId(item.key)
if (isPostOptimisticSnapshot) {
nextDismissedIds.delete(id)
}
if (item.error == null && item.progress != null && item.progress < 100) {
nextSeenActiveIds.add(id)
nextDismissedIds.delete(id)
}
}
seenActiveIds.value = nextSeenActiveIds
dismissedIds.value = nextDismissedIds
if (hasAuthoritativeInstallation) {
optimisticInstallation.value = null
}
}
function begin(key: ServerInstallationKey) {
const id =
key.type === 'unknown' ? `unknown:${++unknownInstallationId}` : installationKeyId(key)
const nextDismissedIds = new Set(dismissedIds.value)
nextDismissedIds.delete(id)
dismissedIds.value = nextDismissedIds
optimisticInstallation.value = {
id,
key,
startRevision: snapshotRevision.value,
}
}
function cancelOptimistic() {
optimisticInstallation.value = null
}
function dismiss(id: string) {
dismissedIds.value = new Set([...dismissedIds.value, id])
if (optimisticInstallation.value?.id === id) {
optimisticInstallation.value = null
}
}
function reset() {
installProgressItems.value = []
optimisticInstallation.value = null
receivedProgressSnapshot.value = false
snapshotRevision.value = 0
seenActiveIds.value = new Set()
dismissedIds.value = new Set()
unknownInstallationId = 0
}
return {
begin,
cancelOptimistic,
dismiss,
handleProgress,
installation,
installProgressItems,
isBlocking,
reset,
}
}
@@ -5,19 +5,23 @@ import {
type UploadState,
} from '@modrinth/api-client'
import type { ComputedRef, Ref } from 'vue'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import type { FileOperation } from '../layouts/shared/files-tab/types'
import { injectModrinthClient, provideModrinthServerContext } from '../providers'
import type { BusyReason, CancelUploadHandler, ServerStats } from '../providers/server-context'
import { defineMessage } from './i18n'
import { useModrinthServersConsole } from './server-console'
import {
retainServerContextRuntime,
type ServerContextRuntimeLease,
} from './server-context-runtime'
import { useServerInstallationTracker } from './server-installation-tracker'
type ReadableRef<T> = Ref<T> | ComputedRef<T>
type SocketUnsubscriber = () => void
type ConnectSocketOptions = {
force?: boolean
extraSubscriptions?: (targetServerId: string) => SocketUnsubscriber[]
}
@@ -26,7 +30,7 @@ type UseServerManageCoreRuntimeOptions = {
worldId: ReadableRef<string | null>
server: ReadableRef<Archon.Servers.v0.Server | null | undefined>
serverFull?: ReadableRef<Archon.Servers.v1.ServerFull | null | undefined>
isSyncingContent: ReadableRef<boolean>
content?: ReadableRef<Archon.Content.v1.Addons | null | undefined>
extraBusyReasons?: ComputedRef<BusyReason[]>
setDisconnectedOnAuthIncorrect?: boolean
syncUptimeFromState?: boolean
@@ -96,10 +100,25 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
const fsAuth = ref<{ url: string; token: string } | null>(null)
const fsOps = ref<Archon.Websocket.v0.FilesystemOperation[]>([])
const fsQueuedOps = ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([])
const {
begin: beginInstallation,
cancelOptimistic: cancelOptimisticInstallation,
dismiss: dismissInstallation,
handleProgress: handleInstallProgress,
installation,
installProgressItems,
isBlocking: isInstallationBlocking,
reset: resetInstallation,
} = useServerInstallationTracker({
worldId: options.worldId,
server: options.server,
content: options.content,
})
const connectedSocketServerId = ref<string | null>(null)
const socketUnsubscribers = ref<SocketUnsubscriber[]>([])
const cpuData = ref<number[]>([])
const ramData = ref<number[]>([])
let serverContextRuntimeLease: ServerContextRuntimeLease | null = null
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
let staleStatsTimeoutId: ReturnType<typeof setTimeout> | null = null
@@ -107,7 +126,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
const busyReasons = computed<BusyReason[]>(() => {
const reasons: BusyReason[] = []
if (options.server.value?.status === 'installing') {
if (isInstallationBlocking.value) {
reasons.push({
reason: defineMessage({
id: 'servers.busy.installing',
@@ -115,14 +134,6 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
}),
})
}
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
})
@@ -265,20 +276,6 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
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 = []
@@ -288,10 +285,8 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
if (!targetServerId && !connectedSocketServerId.value) return
clearSocketListeners()
if (targetServerId) {
client.archon.sockets.disconnect(targetServerId)
}
serverContextRuntimeLease?.release()
serverContextRuntimeLease = null
stopUptimeTicker()
clearStaleStatsTimers()
@@ -301,6 +296,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
serverPowerState.value = 'stopped'
powerStateDetails.value = undefined
uptimeSeconds.value = 0
resetInstallation()
}
const connectSocket = async (
@@ -317,14 +313,11 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
disconnectSocket(connectedSocketServerId.value ?? undefined)
try {
const safeConnectOptions = connectOptions.force ? { force: true } : undefined
await client.archon.sockets.safeConnect(targetServerId, safeConnectOptions)
const runtimeLease = retainServerContextRuntime(client, targetServerId, {
connect: false,
})
serverContextRuntimeLease = runtimeLease
connectedSocketServerId.value = targetServerId
isConnected.value = true
isWsAuthIncorrect.value = false
modrinthServersConsole.clear()
modrinthServersConsole.beginInitialLogHydration()
const baseSubscriptions: SocketUnsubscriber[] = [
client.archon.sockets.on(targetServerId, 'log', handleLog),
@@ -333,15 +326,45 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
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),
watch(
runtimeLease.installProgressItems,
(items) => {
if (shouldProcessEvent()) handleInstallProgress(items)
},
{ immediate: true },
),
watch(
runtimeLease.isSocketAuthenticated,
(authenticated) => {
if (!shouldProcessEvent()) return
if (authenticated || options.setDisconnectedOnAuthIncorrect) {
isConnected.value = authenticated
}
},
{ immediate: true },
),
watch(
runtimeLease.isSocketAuthIncorrect,
(authIncorrect) => {
if (shouldProcessEvent()) isWsAuthIncorrect.value = authIncorrect
},
{ immediate: true },
),
]
const extraSubscriptions = connectOptions.extraSubscriptions?.(targetServerId) ?? []
socketUnsubscribers.value = [...baseSubscriptions, ...extraSubscriptions]
modrinthServersConsole.clear()
modrinthServersConsole.beginInitialLogHydration()
await runtimeLease.waitUntilReady()
isConnected.value = true
isWsAuthIncorrect.value = false
return true
} catch (error) {
console.error('[hosting/manage] Failed to connect server socket:', error)
isConnected.value = false
disconnectSocket(targetServerId)
return false
}
}
@@ -402,7 +425,11 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
isServerRunning,
stats,
uptimeSeconds,
isSyncingContent: options.isSyncingContent as Ref<boolean>,
installProgressItems,
installation,
beginInstallation,
cancelOptimisticInstallation,
dismissInstallation,
busyReasons,
fsAuth,
fsOps,
@@ -423,20 +450,25 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
return {
activeOperations,
beginInstallation,
busyReasons,
cancelUpload,
cancelOptimisticInstallation,
cleanupCoreRuntime,
connectSocket,
connectedSocketServerId,
cpuData,
disconnectSocket,
dismissInstallation,
dismissOperation,
fsAuth,
fsOps,
fsQueuedOps,
installation,
isConnected,
isServerRunning,
isWsAuthIncorrect,
installProgressItems,
powerStateDetails,
ramData,
refreshFsAuth,
+121 -12
View File
@@ -5,11 +5,16 @@ import { onMounted, onUnmounted, watch } from 'vue'
import { injectModrinthClient } from '#ui/providers'
import {
retainServerContextRuntime,
type ServerContextRuntimeLease,
} from './server-context-runtime'
type ReadableRef<T> = Ref<T> | ComputedRef<T>
type SyncUnsubscriber = () => void
type UseServerPanelSyncOptions = {
serverId: ReadableRef<string>
serverId: ReadableRef<string | null>
worldId: ReadableRef<string | null>
}
@@ -20,6 +25,7 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
const queryClient = useQueryClient()
let activeServerId: string | null = null
let runtimeLease: ServerContextRuntimeLease | null = null
let unsubscribers: SyncUnsubscriber[] = []
let mounted = false
let actionLogInvalidateTimer: ReturnType<typeof setTimeout> | null = null
@@ -27,6 +33,8 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
const legacyServerDetailKey = (serverId: string) => ['servers', 'detail', serverId] as const
const serverV1DetailKey = (serverId: string) => ['servers', 'v1', 'detail', serverId] as const
const contentListKey = (serverId: string) => ['content', 'list', 'v1', serverId] as const
const modpackContentListKey = (serverId: string) =>
['content', 'list', 'v1', serverId, 'modpack'] as const
const actionLogBaseKey = (serverId: string) =>
['servers', 'action-log', 'v1', 'infinite', serverId] as const
@@ -43,12 +51,9 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
unsubscribers = [
client.archon.sync.onAny(targetServerId, (event) => handleSyncEvent(targetServerId, event)),
]
void client.archon.sync.safeConnectServer(targetServerId, { intent: 'all' }).catch((error) => {
console.warn(
`[server-panel-sync] Failed to connect sync stream for ${targetServerId}:`,
error,
)
runtimeLease = retainServerContextRuntime(client, targetServerId, {
socket: false,
sync: true,
})
}
@@ -61,10 +66,9 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
for (const unsubscribe of unsubscribers) unsubscribe()
unsubscribers = []
if (activeServerId) {
client.archon.sync.disconnect(activeServerId)
activeServerId = null
}
runtimeLease?.release()
runtimeLease = null
activeServerId = null
}
function handleSyncEvent(serverId: string, event: Archon.Sync.v1.SyncEvent) {
@@ -111,6 +115,9 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
case 'world.content.base.update':
handleWorldContentBaseUpdate(serverId, event)
break
case 'world.content.update':
handleWorldContentUpdate(serverId, event)
break
}
}
@@ -216,6 +223,106 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
void queryClient.invalidateQueries({ queryKey: serverV1DetailKey(serverId) })
}
function handleWorldContentUpdate(
serverId: string,
event: Archon.Sync.v1.WorldContentUpdateEvent,
) {
if (event.world_id !== options.worldId.value) {
void invalidateContentAndServerDetails(serverId)
return
}
const content = worldContentUpdateToAddons(event)
queryClient.setQueryData<Archon.Content.v1.Addons>(contentListKey(serverId), {
...content,
addons: content.addons?.filter((addon) => !addon.from_modpack) ?? null,
})
queryClient.setQueryData<Archon.Content.v1.Addons>(modpackContentListKey(serverId), {
...content,
addons: content.addons?.filter((addon) => addon.from_modpack) ?? null,
})
void queryClient.invalidateQueries({ queryKey: serverV1DetailKey(serverId) })
}
function worldContentUpdateToAddons(
event: Archon.Sync.v1.WorldContentUpdateEvent,
): Archon.Content.v1.Addons {
return {
modloader: event.platform_data?.platform ?? null,
modloader_version: event.platform_data?.platform_version ?? null,
game_version: event.platform_data?.game_version ?? null,
modpack: worldContentModpackToModpackFields(event.linked_modpack),
installing: event.installing,
error: event.error,
addons: event.content.map(worldContentItemToAddon),
}
}
function worldContentModpackToModpackFields(
modpack: Archon.Sync.v1.WorldContentModpack | null,
): Archon.Content.v1.ModpackFields | null {
if (!modpack || modpack.spec === 'CurseForge') return null
const spec: Archon.Content.v1.ModpackSpec =
'Modrinth' in modpack.spec
? {
platform: 'modrinth',
project_id: modpack.spec.Modrinth.project_id,
version_id: modpack.spec.Modrinth.version_id,
}
: {
platform: 'local_file',
filename: modpack.spec.LocalMrPackFile.path,
name: modpack.spec.LocalMrPackFile.name,
description: modpack.spec.LocalMrPackFile.description,
}
return {
spec,
has_update: modpack.has_update,
title: modpack.title,
description: modpack.description,
icon_url: modpack.icon_url,
owner: modpack.owner,
version_number: modpack.version_number,
date_published: modpack.date_published,
downloads: modpack.downloads,
followers: modpack.followers,
}
}
function worldContentItemToAddon(item: Archon.Sync.v1.WorldContentItem): Archon.Content.v1.Addon {
return {
id: item.version?.id ?? item.version_id ?? item.file_sha1 ?? item.filename,
filename: item.filename,
filesize: item.filesize ?? 0,
btime: item.btime,
disabled: item.filename.endsWith('.disabled'),
kind: parentDirectoryToAddonKind(item.parent_directory),
from_modpack: item.from_modpack,
status: item.status,
pack_client_retained: item.pack_client_retained,
pack_client_depends: item.pack_client_depends,
has_update: item.has_update,
name: item.name,
project_id: item.project_id,
version: item.version,
owner: item.owner,
icon_url: item.icon_url,
}
}
function parentDirectoryToAddonKind(parentDirectory: string): Archon.Content.v1.AddonKind {
switch (parentDirectory) {
case 'plugins':
return 'plugin'
case 'datapacks':
return 'datapack'
default:
return 'mod'
}
}
function handleBackupEvent(serverId: string) {
void queryClient.invalidateQueries({ queryKey: ['backups', 'queue', serverId] })
void invalidateServerDetails(serverId)
@@ -296,7 +403,9 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
return {
...current,
...incoming,
status: incoming.status ?? current.status,
filesize: incoming.filesize || current.filesize,
btime: incoming.btime ?? current.btime,
name: incoming.name ?? current.name,
owner: incoming.owner ?? current.owner,
icon_url: incoming.icon_url ?? current.icon_url,
@@ -318,7 +427,7 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
onMounted(() => {
mounted = true
connect(options.serverId.value)
if (options.serverId.value) connect(options.serverId.value)
})
watch(