diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index c841f5fc9c..fe816f8a9e 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -134,6 +134,7 @@ const route = useRoute() const APP_LEFT_NAV_WIDTH = '4rem' const APP_SIDEBAR_WIDTH = 300 const INTERCOM_BUBBLE_DEFAULT_PADDING = 20 +const ROUTE_SUSPENSE_TIMEOUT_MS = 60_000 const credentials = ref() const sidebarToggled = ref(true) const unsubscribeSidebarToggle = themeStore.$subscribe(() => { @@ -143,6 +144,22 @@ const forceSidebar = computed( () => route.path.startsWith('/browse') || route.path.startsWith('/project'), ) const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value) +const keepAliveRouteComponents = computed(() => [ + ...new Set( + router + .getRoutes() + .map((route) => route.meta.keepAliveComponent) + .filter((name) => typeof name === 'string'), + ), +]) + +function getRouteViewKey(viewRoute) { + const keepAliveKey = viewRoute.meta.keepAliveKey + if (typeof keepAliveKey === 'function') return keepAliveKey(viewRoute) + if (typeof keepAliveKey === 'string') return keepAliveKey + return undefined +} + const hostingRouteActive = computed(() => route.path.startsWith('/hosting')) const hostingIntercomIdentityKey = computed(() => { const rawServerId = route.params.id @@ -482,6 +499,11 @@ const sidebarOverlayScrollbarsOptions = Object.freeze({ }, }) +router.beforeEach(async (to) => { + const redirect = await resolveLegacyServerInstanceTabRedirect(to) + if (redirect) return redirect +}) + router.beforeEach(() => { suspensePending = false if (routerToken) loading.end(routerToken) @@ -513,6 +535,50 @@ function onSuspensePending() { suspenseToken = loading.begin() } +async function resolveLegacyServerInstanceTabRedirect(to) { + if (!['ServerManageContent', 'ServerManageFiles', 'ServerManageBackups'].includes(to.name)) { + return null + } + + const serverId = getRouteParam(to.params.id) + if (!serverId) return null + + const tabPath = + to.name === 'ServerManageFiles' ? '/files' : to.name === 'ServerManageBackups' ? '/backups' : '' + const instancesPath = `/hosting/manage/${encodeURIComponent(serverId)}/instances` + + try { + const serverFull = await tauriApiClient.archon.servers_v1.get(serverId) + const world = serverFull.worlds.find((item) => item.is_active) ?? serverFull.worlds[0] + if (world) { + return { + path: `${instancesPath}/${encodeURIComponent(world.id)}${tabPath}`, + query: to.query, + hash: to.hash, + replace: true, + } + } + } catch { + return { + path: instancesPath, + query: to.query, + hash: to.hash, + replace: true, + } + } + + return { + path: instancesPath, + query: to.query, + hash: to.hash, + replace: true, + } +} + +function getRouteParam(param) { + return Array.isArray(param) ? param[0] : param +} + function onSuspenseResolve() { if (suspenseToken) { loading.end(suspenseToken) @@ -1446,11 +1512,17 @@ provideAppUpdateDownloadProgress(appUpdateDownload) > {{ formatMessage(messages.authUnreachableBody) }} - + diff --git a/apps/app-frontend/src/composables/browse/use-app-server-browse.ts b/apps/app-frontend/src/composables/browse/use-app-server-browse.ts index 1a0fe431d2..628d434bd3 100644 --- a/apps/app-frontend/src/composables/browse/use-app-server-browse.ts +++ b/apps/app-frontend/src/composables/browse/use-app-server-browse.ts @@ -2,16 +2,21 @@ import type { Labrinth } from '@modrinth/api-client' import { CheckIcon, PlayIcon, PlusIcon, StopCircleIcon } from '@modrinth/assets' import type { CardAction } from '@modrinth/ui' import { commonMessages, defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui' +import { useQueryClient } from '@tanstack/vue-query' import { openUrl } from '@tauri-apps/plugin-opener' import type { ComputedRef, Ref } from 'vue' import { onUnmounted, ref, shallowRef } from 'vue' import type { Router } from 'vue-router' +import { + fetchCachedServerStatus, + getFreshCachedServerStatus, +} from '@/composables/instances/use-server-status-query' import { process_listener } from '@/helpers/events' import { get_by_profile_path } from '@/helpers/process' import { kill, list as listInstances } from '@/helpers/profile.js' import type { GameInstance } from '@/helpers/types' -import { add_server_to_profile, getServerLatency } from '@/helpers/worlds' +import { add_server_to_profile } from '@/helpers/worlds' import { getServerAddress } from '@/store/install.js' interface BrowseServerInstance { @@ -65,14 +70,13 @@ const messages = defineMessages({ export function useAppServerBrowse(options: UseAppServerBrowseOptions) { const { formatMessage } = useVIntl() + const queryClient = useQueryClient() const debugLog = useDebugLogger('BrowseServer') const serverPings = shallowRef>({}) - const serverPingCache = new Map() - const pendingServerPings = new Map>() const runningServerProjects = ref>({}) const lastServerHits = shallowRef([]) const contextMenuRef = ref(null) - let serverPingCacheActive = true + let serverPingsActive = true let unlistenProcesses: (() => void) | null = null async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) { @@ -145,37 +149,26 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) { }) const nextPings = { ...serverPings.value } for (const { hit, address } of pingsToFetch) { - if (serverPingCache.has(address)) { - nextPings[hit.project_id] = serverPingCache.get(address) + const cachedStatus = getFreshCachedServerStatus(queryClient, address) + if (cachedStatus) { + nextPings[hit.project_id] = cachedStatus.ping } } serverPings.value = nextPings await Promise.all( pingsToFetch.map(async ({ hit, address }) => { - if (serverPingCache.has(address)) return + if (getFreshCachedServerStatus(queryClient, address)) return - let pending = pendingServerPings.get(address) - if (!pending) { - pending = getServerLatency(address) - .then((latency) => { - if (serverPingCacheActive) serverPingCache.set(address, latency) - return latency - }) - .catch((error) => { - console.error(`Failed to ping server ${address}:`, error) - if (serverPingCacheActive) serverPingCache.set(address, undefined) - return undefined - }) - .finally(() => { - pendingServerPings.delete(address) - }) - pendingServerPings.set(address, pending) + try { + const status = await fetchCachedServerStatus(queryClient, address) + if (!serverPingsActive) return + serverPings.value = { ...serverPings.value, [hit.project_id]: status.ping } + } catch (error) { + console.error(`Failed to ping server ${address}:`, error) + if (!serverPingsActive) return + serverPings.value = { ...serverPings.value, [hit.project_id]: undefined } } - - const latency = await pending - if (!serverPingCacheActive) return - serverPings.value = { ...serverPings.value, [hit.project_id]: latency } }), ) } @@ -307,10 +300,8 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) { .catch(options.handleError) onUnmounted(() => { - serverPingCacheActive = false + serverPingsActive = false unlistenProcesses?.() - serverPingCache.clear() - pendingServerPings.clear() }) return { diff --git a/apps/app-frontend/src/composables/instances/use-server-status-query.ts b/apps/app-frontend/src/composables/instances/use-server-status-query.ts new file mode 100644 index 0000000000..a13387ef61 --- /dev/null +++ b/apps/app-frontend/src/composables/instances/use-server-status-query.ts @@ -0,0 +1,50 @@ +import type { QueryClient } from '@tanstack/vue-query' + +import { + get_server_status, + normalizeServerAddress, + type ProtocolVersion, + type ServerStatus, +} from '@/helpers/worlds' + +export const SERVER_STATUS_CACHE_MS = 10 * 60 * 1000 + +function getProtocolVersionKey(protocolVersion: ProtocolVersion | null) { + if (!protocolVersion) return 'default' + return `${protocolVersion.version}:${protocolVersion.legacy ? 'legacy' : 'modern'}` +} + +export function getServerStatusQueryKey( + address: string, + protocolVersion: ProtocolVersion | null = null, +) { + return [ + 'minecraft-server-status', + normalizeServerAddress(address) || address.trim().toLowerCase(), + getProtocolVersionKey(protocolVersion), + ] as const +} + +export function getFreshCachedServerStatus( + queryClient: QueryClient, + address: string, + protocolVersion: ProtocolVersion | null = null, +) { + const queryKey = getServerStatusQueryKey(address, protocolVersion) + const updatedAt = queryClient.getQueryState(queryKey)?.dataUpdatedAt ?? 0 + if (!updatedAt || Date.now() - updatedAt >= SERVER_STATUS_CACHE_MS) return undefined + return queryClient.getQueryData(queryKey) +} + +export async function fetchCachedServerStatus( + queryClient: QueryClient, + address: string, + protocolVersion: ProtocolVersion | null = null, +) { + return await queryClient.fetchQuery({ + queryKey: getServerStatusQueryKey(address, protocolVersion), + queryFn: () => get_server_status(address, protocolVersion), + staleTime: SERVER_STATUS_CACHE_MS, + gcTime: SERVER_STATUS_CACHE_MS, + }) +} diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index e6362c6de6..4c769522a8 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -143,6 +143,9 @@ "app.browse.server.installing": { "message": "Installing" }, + "app.browse.server.world-fallback-name": { + "message": "Instance" + }, "app.creation-modal.installing-modpack.description": { "message": "{fileName}" }, diff --git a/apps/app-frontend/src/pages/Browse.vue b/apps/app-frontend/src/pages/Browse.vue index e256d5b024..241271478a 100644 --- a/apps/app-frontend/src/pages/Browse.vue +++ b/apps/app-frontend/src/pages/Browse.vue @@ -10,6 +10,7 @@ import { } from '@modrinth/assets' import type { BrowseInstallContentType, CardAction, ProjectType, Tags } from '@modrinth/ui' import { + BrowseInstallHeader, BrowsePageLayout, BrowseSidebar, commonMessages, @@ -22,8 +23,9 @@ import { preferencesDiffer, provideBrowseManager, requestInstall, + SelectedProjectsFloatingBar, useBrowseSearch, - useDebugLogger, + useStickyObserver, useVIntl, } from '@modrinth/ui' import { useQueryClient } from '@tanstack/vue-query' @@ -56,18 +58,25 @@ import { } from '@/providers/setup/server-install-content' import { useBreadcrumbs } from '@/store/breadcrumbs' +defineOptions({ + name: 'Browse', +}) + const { handleError } = injectNotificationManager() const { formatMessage } = useVIntl() const { installingServerProjects, playServerProject, showAddServerToInstanceModal } = injectServerInstall() const { install: installVersion } = injectContentInstall() const queryClient = useQueryClient() -const debugLog = useDebugLogger('Browse') const router = useRouter() const route = useRoute() +const browseRouteActive = computed(() => route.path.startsWith('/browse/')) const serverSetupModalRef = ref | null>(null) -const serverInstallContent = createServerInstallContent({ serverSetupModalRef }) +const serverInstallContent = createServerInstallContent({ + serverSetupModalRef, + isRouteInContext: (targetRoute) => targetRoute.path.startsWith('/browse/'), +}) provideServerInstallContent(serverInstallContent) const { serverIdQuery, @@ -77,6 +86,7 @@ const { isSetupServerContext, effectiveServerWorldId, serverContextServerData, + serverContextWorldName, serverContentProjectIds, queuedServerInstallProjectIds, queuedServerInstallCount, @@ -103,8 +113,6 @@ const { handleServerModpackFlowCreate, markServerProjectInstalled, } = serverInstallContent - -debugLog('fetching tags (categories, loaders, gameVersions)') const [categories, loaders, availableGameVersions] = await Promise.all([ get_categories() .catch(handleError) @@ -182,22 +190,10 @@ watchServerContextChanges() await initInstanceContext() async function initInstanceContext() { - debugLog('initInstanceContext', { - queryI: route.query.i, - queryAi: route.query.ai, - querySid: route.query.sid, - queryWid: route.query.wid, - queryFrom: route.query.from, - }) await initServerContext() if (route.query.i) { instance.value = (await getInstance(route.query.i as string).catch(handleError)) ?? null - debugLog('instance loaded', { - name: instance.value?.name, - loader: instance.value?.loader, - gameVersion: instance.value?.game_version, - }) if (route.query.from === 'worlds') { get_profile_worlds(route.query.i as string) @@ -205,34 +201,29 @@ async function initInstanceContext() { const serverProjectIds = worlds .filter((w) => w.type === 'server' && 'project_id' in w && w.project_id) .map((w) => (w as { project_id: string }).project_id) - debugLog('installedServerProjectIds loaded', { count: serverProjectIds.length }) installedProjectIds.value = serverProjectIds }) .catch(handleError) } else { getInstalledProjectIds(route.query.i as string) .then((ids) => { - debugLog('installedProjectIds loaded', { count: ids?.length }) installedProjectIds.value = ids }) .catch(handleError) } if (instance.value?.linked_data?.project_id) { - debugLog('checking linked project for server status', instance.value.linked_data.project_id) const projectV3 = await get_project_v3( instance.value.linked_data.project_id, 'must_revalidate', ).catch(handleError) if (projectV3?.minecraft_server != null) { - debugLog('instance is a server instance') isServerInstance.value = true } } } if (route.query.ai && !(route.params.projectType === 'modpack')) { - debugLog('setting instanceHideInstalled from query', route.query.ai) instanceHideInstalled.value = route.query.ai === 'true' } } @@ -283,7 +274,7 @@ function syncHiddenServerContentProjectIds() { watch( serverContentProjectIds, () => { - if (!hiddenServerContentProjectIdsInitialized.value) { + if (!hiddenServerContentProjectIdsInitialized.value || serverHideInstalled.value) { syncHiddenServerContentProjectIds() } }, @@ -356,11 +347,9 @@ const { const offline = ref(!navigator.onLine) window.addEventListener('offline', () => { - debugLog('went offline') offline.value = true }) window.addEventListener('online', () => { - debugLog('went online') offline.value = false }) @@ -405,6 +394,10 @@ const messages = defineMessages({ id: 'app.browse.back-to-instance', defaultMessage: 'Back to instance', }, + worldFallbackName: { + id: 'app.browse.server.world-fallback-name', + defaultMessage: 'Instance', + }, serverInstanceContentWarning: { id: 'app.browse.server-instance-content-warning', defaultMessage: @@ -464,6 +457,9 @@ const projectType = ref(route.params.projectType as ProjectType) watch( () => route.params.projectType as ProjectType, async (newType) => { + if (!browseRouteActive.value) { + return + } if (isSetupServerContext.value) { enforceSetupModpackRoute(newType) if (newType !== 'modpack') return @@ -471,11 +467,9 @@ watch( if (!newType || newType === projectType.value) return - debugLog('projectType route param changed', { from: projectType.value, to: newType }) projectType.value = newType if (!route.query.i && instance.value) { - debugLog('instance context removed, resetting') instance.value = null installedProjectIds.value = null instanceHideInstalled.value = false @@ -545,8 +539,9 @@ const selectableProjectTypes = computed(() => { const installContext = computed(() => { if (isServerContext.value && serverContextServerData.value) { return { - name: serverContextServerData.value.name, + name: serverContextWorldName.value ?? formatMessage(messages.worldFallbackName), loader: serverContextServerData.value.loader ?? '', + loaderVersion: serverContextServerData.value.loader_version ?? '', gameVersion: serverContextServerData.value.mc_version ?? '', serverId: serverIdQuery.value, upstream: serverContextServerData.value.upstream, @@ -586,6 +581,12 @@ const installContext = computed(() => { return null }) +const stickyInstallHeaderRef = ref(null) +const { isStuck: isInstallHeaderStuck } = useStickyObserver( + stickyInstallHeaderRef, + 'BrowseInstallHeader', +) + const installingProjectIds = ref>(new Set()) function setProjectInstalling(projectId: string, installing: boolean) { @@ -682,11 +683,10 @@ function getCardActions( installed?: boolean installing?: boolean } - const isInstalled = - projectResult.installed || - allInstalledIds.value.has(projectResult.project_id || '') || - serverContentProjectIds.value.has(projectResult.project_id || '') || - serverContextServerData.value?.upstream?.project_id === projectResult.project_id + const isInstalled = isServerContext.value + ? serverContentProjectIds.value.has(projectResult.project_id || '') || + serverContextServerData.value?.upstream?.project_id === projectResult.project_id + : projectResult.installed || allInstalledIds.value.has(projectResult.project_id || '') const isInstalling = installingProjectIds.value.has(projectResult.project_id) if ( @@ -838,7 +838,6 @@ function onSearchResultInstalled(id: string) { } async function search(requestParams: string) { - debugLog('searching v3', requestParams) const isServer = projectType.value === 'server' const rawResults = await queryClient.fetchQuery({ @@ -920,6 +919,7 @@ const lockedFilterMessages = computed(() => ({ const searchState = useBrowseSearch({ projectType, tags, + active: browseRouteActive, providedFilters: combinedProvidedFilters, search, persistentQueryParams: ['i', 'ai', 'shi', 'sid', 'wid', 'from'], @@ -967,7 +967,12 @@ if (instance.value?.game_version) { await searchState.refreshSearch() function getProjectBrowseQuery() { - if (!installContext.value) return undefined + if (!browseRouteActive.value) { + return undefined + } + if (!installContext.value) { + return undefined + } return { ...route.query, b: route.fullPath, @@ -1031,6 +1036,17 @@ provideBrowseManager({