mirror of
https://github.com/modrinth/code.git
synced 2026-08-27 01:54:47 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user