diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 041209b551..54d9320c26 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -5,6 +5,7 @@ import { nodeAuthState, PanelVersionFeature, TauriModrinthClient, + VerboseLoggingFeature, } from '@modrinth/api-client' import { ArrowBigUpDashIcon, @@ -146,6 +147,7 @@ const tauriApiClient = new TauriModrinthClient({ token: async () => (await getCreds())?.session, }), new PanelVersionFeature(), + new VerboseLoggingFeature(), ], }) provideModrinthClient(tauriApiClient) @@ -420,6 +422,7 @@ const route = useRoute() const loading = useLoading() loading.setEnabled(false) +loading.startLoading() const error = useError() const errorModal = ref() @@ -1023,6 +1026,8 @@ provideAppUpdateDownloadProgress(appUpdateDownload) v-if="themeStore.featureFlags.servers_in_app" v-tooltip.right="'Servers'" to="/hosting/manage" + :is-primary="(r) => r.path === '/hosting/manage' || r.path === '/hosting/manage/'" + :is-subpage="(r) => r.path.startsWith('/hosting/manage/') && r.path !== '/hosting/manage/'" > @@ -1195,7 +1200,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
- {{ - installing - ? 'Installing' - : installed - ? 'Installed' - : modpack || instance - ? 'Install' - : 'Add to an instance' - }} + {{ installActionLabel }} @@ -109,14 +101,44 @@ const props = defineProps({ type: String, default: null, }, + customInstall: { + type: Function, + default: null, + }, }) const emit = defineEmits(['open', 'install']) const installing = ref(false) +const installActionLabel = computed(() => + installing.value + ? 'Installing' + : props.installed + ? 'Installed' + : props.customInstall || modpack.value || props.instance + ? 'Install' + : 'Add to an instance', +) +const shouldUseInstallIcon = computed( + () => !!props.customInstall || !!modpack.value || !!props.instance, +) async function install() { installing.value = true + if (props.customInstall) { + try { + const didInstall = await props.customInstall(props.project) + if (didInstall !== false) { + emit('install', props.project.project_id ?? props.project.id) + } + } catch (err) { + handleError(err) + } finally { + installing.value = false + } + return + } + await installVersion( props.project.project_id ?? props.project.id, null, diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index d93726e438..f7f558ae65 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -185,9 +185,6 @@ "app.modal.update-to-play.update-required-description": { "message": "An update is required to play {name}. Please update to the latest version to launch the game." }, - "app.server-settings.failed-to-load-server": { - "message": "Failed to load server settings" - }, "app.settings.developer-mode-enabled": { "message": "Developer mode enabled." }, @@ -601,11 +598,5 @@ }, "search.filter.locked.server-loader.title": { "message": "Loader is provided by the server" - }, - "servers.busy.installing": { - "message": "Server is installing" - }, - "servers.busy.syncing-content": { - "message": "Content sync in progress" } } diff --git a/apps/app-frontend/src/pages/Browse.vue b/apps/app-frontend/src/pages/Browse.vue index f7ec4dcef4..cac5cbff29 100644 --- a/apps/app-frontend/src/pages/Browse.vue +++ b/apps/app-frontend/src/pages/Browse.vue @@ -5,6 +5,7 @@ import { ClipboardCopyIcon, ExternalIcon, GlobeIcon, + LeftArrowIcon, PlayIcon, PlusIcon, SearchIcon, @@ -16,6 +17,7 @@ import { ButtonStyled, Checkbox, commonMessages, + CreationFlowModal, defineMessages, DropdownSelect, injectNotificationManager, @@ -39,15 +41,14 @@ import type { LocationQuery } from 'vue-router' import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router' import ContextMenu from '@/components/ui/ContextMenu.vue' -import type Instance from '@/components/ui/Instance.vue' import InstanceIndicator from '@/components/ui/InstanceIndicator.vue' import SearchCard from '@/components/ui/SearchCard.vue' import { get_project_v3, get_search_results_v3 } from '@/helpers/cache.js' import { process_listener } from '@/helpers/events' import { get_by_profile_path } from '@/helpers/process' import { - get as getInstance, get_installed_project_ids as getInstalledProjectIds, + get as getInstance, kill, list as listInstances, } from '@/helpers/profile.js' @@ -55,6 +56,10 @@ import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags' import type { GameInstance } from '@/helpers/types' import { add_server_to_profile, get_profile_worlds, getServerLatency } from '@/helpers/worlds' import { injectServerInstall } from '@/providers/server-install' +import { + createServerInstallContent, + provideServerInstallContent, +} from '@/providers/setup/server-install-content' import { useBreadcrumbs } from '@/store/breadcrumbs' import { getServerAddress } from '@/store/install.js' @@ -66,6 +71,31 @@ const debugLog = useDebugLogger('Browse') const router = useRouter() const route = useRoute() +const serverSetupModalRef = ref | null>(null) +const serverInstallContent = createServerInstallContent({ serverSetupModalRef }) +provideServerInstallContent(serverInstallContent) +const { + serverIdQuery, + serverFlowFrom, + isFromWorlds, + isServerContext, + isSetupServerContext, + effectiveServerWorldId, + serverContextServerData, + serverContentProjectIds, + serverBackUrl, + serverBackLabel, + serverBrowseHeading, + initServerContext, + watchServerContextChanges, + searchServerModpacks, + getServerProjectVersions, + enforceSetupModpackRoute, + installProjectToServer, + onServerFlowBack, + handleServerModpackFlowCreate, + markServerProjectInstalled, +} = serverInstallContent const projectTypes = computed(() => { debugLog('projectTypes computed', route.params.projectType) @@ -110,7 +140,6 @@ const installedProjectIds: Ref = ref(null) const instanceHideInstalled = ref(false) const newlyInstalled = ref([]) const isServerInstance = ref(false) -const isFromWorlds = computed(() => route.query.from === 'worlds') if (isFromWorlds.value && route.params.projectType !== 'server') { router.replace({ @@ -119,16 +148,28 @@ if (isFromWorlds.value && route.params.projectType !== 'server') { }) } +enforceSetupModpackRoute(route.params.projectType as string | undefined) + const allInstalledIds = computed( () => new Set([...newlyInstalled.value, ...(installedProjectIds.value ?? [])]), ) -const PERSISTENT_QUERY_PARAMS = ['i', 'ai', 'from'] +const PERSISTENT_QUERY_PARAMS = ['i', 'ai', 'sid', 'wid', 'from'] + +watchServerContextChanges() await initInstanceContext() async function initInstanceContext() { - debugLog('initInstanceContext', { queryI: route.query.i, queryAi: route.query.ai }) + 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', { @@ -267,6 +308,14 @@ const activeGameVersion = computed(() => { return instance.value?.game_version ?? null }) +function onSearchResultInstalled(id: string) { + if (isServerContext.value) { + markServerProjectInstalled(id) + return + } + newlyInstalled.value.push(id) +} + const serverHits = shallowRef([]) const filteredServerHits = computed(() => { if (!instanceHideInstalled.value || allInstalledIds.value.size === 0) return serverHits.value @@ -581,11 +630,10 @@ async function refreshSearch() { page: 1, } } else { - if (instance.value) { - const allInstalledIds = new Set([ - ...newlyInstalled.value, - ...(installedProjectIds.value ?? []), - ]) + if (instance.value || isServerContext.value) { + const allInstalledIds = instance.value + ? new Set([...newlyInstalled.value, ...(installedProjectIds.value ?? [])]) + : serverContentProjectIds.value rawResults.result.hits = rawResults.result.hits.map((val) => ({ ...val, @@ -625,6 +673,13 @@ async function refreshSearch() { } } + if (serverIdQuery.value) { + persistentParams.sid = serverIdQuery.value + if (effectiveServerWorldId.value) { + persistentParams.wid = effectiveServerWorldId.value + } + } + if (instanceHideInstalled.value) { persistentParams.ai = 'true' } else { @@ -673,6 +728,11 @@ function clearSearch() { watch( () => route.params.projectType as ProjectType, async (newType) => { + if (isSetupServerContext.value) { + enforceSetupModpackRoute(newType) + if (newType !== 'modpack') return + } + // Check if the newType is not the same as the current value if (!newType || newType === projectType.value) return @@ -731,10 +791,20 @@ const selectableProjectTypes = computed(() => { if (route.query.from) { params.from = route.query.from } + if (route.query.sid) { + params.sid = route.query.sid + } + if (effectiveServerWorldId.value) { + params.wid = effectiveServerWorldId.value + } const queryString = new URLSearchParams(params as Record).toString() const suffix = queryString ? `?${queryString}` : '' + if (isSetupServerContext.value) { + return [{ label: 'Modpacks', href: `/browse/modpack${suffix}` }] + } + if (isFromWorlds.value) { return [{ label: 'Servers', href: `/browse/server${suffix}` }] } @@ -897,7 +967,24 @@ previousFilterState.value = JSON.stringify({
- @@ -1128,5 +1220,19 @@ previousFilterState.value = JSON.stringify({ />
+ + diff --git a/apps/app-frontend/src/pages/hosting/manage/Index.vue b/apps/app-frontend/src/pages/hosting/manage/Index.vue index fc1015e7a0..1b9eb37ab7 100644 --- a/apps/app-frontend/src/pages/hosting/manage/Index.vue +++ b/apps/app-frontend/src/pages/hosting/manage/Index.vue @@ -1,346 +1,80 @@ diff --git a/apps/app-frontend/src/pages/hosting/manage/Overview.vue b/apps/app-frontend/src/pages/hosting/manage/Overview.vue new file mode 100644 index 0000000000..7b29977bb4 --- /dev/null +++ b/apps/app-frontend/src/pages/hosting/manage/Overview.vue @@ -0,0 +1,7 @@ + + + diff --git a/apps/app-frontend/src/pages/hosting/manage/index.js b/apps/app-frontend/src/pages/hosting/manage/index.js index 127c22afce..50052e3f9e 100644 --- a/apps/app-frontend/src/pages/hosting/manage/index.js +++ b/apps/app-frontend/src/pages/hosting/manage/index.js @@ -2,5 +2,6 @@ import Backups from './Backups.vue' import Content from './Content.vue' import Files from './Files.vue' import Index from './Index.vue' +import Overview from './Overview.vue' -export { Backups, Content, Files, Index } +export { Backups, Content, Files, Index, Overview } diff --git a/apps/app-frontend/src/providers/setup/server-install-content.ts b/apps/app-frontend/src/providers/setup/server-install-content.ts new file mode 100644 index 0000000000..35919d9d73 --- /dev/null +++ b/apps/app-frontend/src/providers/setup/server-install-content.ts @@ -0,0 +1,393 @@ +import type { Archon, Labrinth } from '@modrinth/api-client' +import { + createContext, + type CreationFlowContextValue, + injectModrinthClient, + injectNotificationManager, +} from '@modrinth/ui' +import { computed, type ComputedRef, nextTick, type Ref, ref, watch } from 'vue' +import { useRoute, useRouter } from 'vue-router' + +type ServerFlowFrom = 'onboarding' | 'reset-server' +type ServerInstallableType = 'modpack' | 'mod' | 'plugin' | 'datapack' + +type InstallableSearchResult = Labrinth.Search.v3.ResultSearchProject & { + installing?: boolean + installed?: boolean +} + +interface ServerModpackSelectionRequest { + projectId: string + versionId: string + name: string + iconUrl?: string +} + +interface ServerSetupModalHandle { + show: () => void | Promise + hide: () => void + ctx?: CreationFlowContextValue | null +} + +export interface ServerInstallContentContext { + serverIdQuery: ComputedRef + worldIdQuery: ComputedRef + browseFrom: ComputedRef + serverFlowFrom: ComputedRef + isFromWorlds: ComputedRef + isServerContext: ComputedRef + isSetupServerContext: ComputedRef + effectiveServerWorldId: ComputedRef + serverContextServerData: Ref + serverContentProjectIds: Ref> + serverBackUrl: ComputedRef + serverBackLabel: ComputedRef + serverBrowseHeading: ComputedRef + initServerContext: () => Promise + watchServerContextChanges: () => void + searchServerModpacks: ( + query: string, + limit?: number, + ) => Promise + getServerProjectVersions: (projectId: string) => Promise<{ id: string }[]> + enforceSetupModpackRoute: (currentProjectType: string | undefined) => void + installProjectToServer: (project: InstallableSearchResult) => Promise + onServerFlowBack: () => void + handleServerModpackFlowCreate: (config: CreationFlowContextValue) => Promise + markServerProjectInstalled: (id: string) => void +} + +export const [injectServerInstallContent, provideServerInstallContent] = + createContext('Browse', 'serverInstallContent') + +function readQueryString(value: unknown): string | null { + if (Array.isArray(value)) return value[0] ?? null + return typeof value === 'string' && value.length > 0 ? value : null +} + +export function createServerInstallContent(opts: { + serverSetupModalRef: Ref +}) { + const { serverSetupModalRef } = opts + const route = useRoute() + const router = useRouter() + const client = injectModrinthClient() + const { handleError } = injectNotificationManager() + + const serverIdQuery = computed(() => readQueryString(route.query.sid)) + const worldIdQuery = computed(() => readQueryString(route.query.wid)) + const browseFrom = computed(() => readQueryString(route.query.from)) + const serverFlowFrom = computed(() => + browseFrom.value === 'onboarding' || browseFrom.value === 'reset-server' + ? browseFrom.value + : null, + ) + + const isFromWorlds = computed(() => browseFrom.value === 'worlds') + const isServerContext = computed(() => !!serverIdQuery.value) + const isSetupServerContext = computed(() => !!serverIdQuery.value && !!serverFlowFrom.value) + + const serverContextWorldId = ref(worldIdQuery.value) + const serverContextServerData = ref(null) + const serverContentProjectIds = ref>(new Set()) + const effectiveServerWorldId = computed(() => worldIdQuery.value ?? serverContextWorldId.value) + + const serverBackUrl = computed(() => { + const sid = serverIdQuery.value + if (!sid) return '/hosting/manage' + if (serverFlowFrom.value === 'onboarding') { + return `/hosting/manage/${sid}?resumeModal=setup-type` + } + if (serverFlowFrom.value === 'reset-server') { + return `/hosting/manage/${sid}?openSettings=installation` + } + return `/hosting/manage/${sid}/content` + }) + const serverBackLabel = computed(() => { + if (serverFlowFrom.value === 'onboarding') return 'Back to setup' + if (serverFlowFrom.value === 'reset-server') return 'Cancel reset' + return 'Back to server' + }) + const serverBrowseHeading = computed(() => { + if (serverFlowFrom.value === 'reset-server') { + return 'Select modpack to install after reset' + } + return 'Install content to server' + }) + + async function resolveServerContextWorldId(serverId: string) { + try { + const server = await client.archon.servers_v1.get(serverId) + const activeWorld = server.worlds.find((world) => world.is_active) + return activeWorld?.id ?? server.worlds[0]?.id ?? null + } catch (err) { + handleError(err as Error) + return null + } + } + + async function refreshServerInstalledContent(serverId: string, worldId: string) { + try { + const content = await client.archon.content_v1.getAddons(serverId, worldId) + const ids = new Set( + (content.addons ?? []) + .map((addon) => addon.project_id) + .filter((projectId): projectId is string => !!projectId), + ) + serverContentProjectIds.value = ids + } catch (err) { + handleError(err as Error) + } + } + + async function initServerContext() { + const sid = serverIdQuery.value + if (!sid) return + + try { + serverContextServerData.value = await client.archon.servers_v0.get(sid) + } catch (err) { + handleError(err as Error) + } + + let resolvedWorldId = effectiveServerWorldId.value + if (!resolvedWorldId) { + resolvedWorldId = await resolveServerContextWorldId(sid) + if (resolvedWorldId) { + serverContextWorldId.value = resolvedWorldId + } + } + + if (resolvedWorldId) { + await refreshServerInstalledContent(sid, resolvedWorldId) + } + } + + function watchServerContextChanges() { + watch([serverIdQuery, effectiveServerWorldId], async ([sid, wid], [prevSid, prevWid]) => { + if (!sid) { + serverContextServerData.value = null + serverContentProjectIds.value = new Set() + return + } + + if (sid !== prevSid) { + serverContentProjectIds.value = new Set() + try { + serverContextServerData.value = await client.archon.servers_v0.get(sid) + } catch (err) { + handleError(err as Error) + } + } + + if (wid && (sid !== prevSid || wid !== prevWid)) { + await refreshServerInstalledContent(sid, wid) + } + }) + } + + function normalizeLoader(loader: string) { + return loader.toLowerCase().replaceAll('_', '').replaceAll('-', '').replaceAll(' ', '') + } + + function getCompatibleLoaders(loader: string) { + const normalized = normalizeLoader(loader) + if (!normalized) return new Set() + if (normalized === 'paper' || normalized === 'purpur' || normalized === 'spigot') { + return new Set(['paper', 'purpur', 'spigot', 'bukkit']) + } + if (normalized === 'neoforge' || normalized === 'neo') { + return new Set(['neoforge', 'neo']) + } + return new Set([normalized]) + } + + function enforceSetupModpackRoute(currentProjectType: string | undefined) { + if (!isSetupServerContext.value || currentProjectType === 'modpack') return + router.replace({ + path: '/browse/modpack', + query: route.query, + }) + } + + async function searchServerModpacks(query: string, limit: number = 10) { + return client.labrinth.projects_v2.search({ + query: query || undefined, + new_filters: + 'project_types = "modpack" AND (client_side = "optional" OR client_side = "required") AND server_side = "required"', + limit, + }) + } + + async function getServerProjectVersions(projectId: string) { + const versions = await client.labrinth.versions_v3.getProjectVersions(projectId) + return versions.map((version) => ({ id: version.id })) + } + + async function openServerModpackInstallFlow(request: ServerModpackSelectionRequest) { + if (!serverIdQuery.value || !effectiveServerWorldId.value) { + throw new Error('Missing server context') + } + + const modalInstance = serverSetupModalRef.value + if (!modalInstance) return + + modalInstance.show() + await nextTick() + + const ctx = modalInstance.ctx + if (!ctx) return + + ctx.setupType.value = 'modpack' + ctx.modpackSelection.value = { + projectId: request.projectId, + versionId: request.versionId, + name: request.name, + iconUrl: request.iconUrl, + } + ctx.modal.value?.setStage('final-config') + } + + function getCurrentServerInstallType(): ServerInstallableType { + const raw = Array.isArray(route.params.projectType) + ? route.params.projectType[0] + : route.params.projectType + if (raw === 'modpack' || raw === 'mod' || raw === 'plugin' || raw === 'datapack') { + return raw + } + throw new Error('This content type cannot be installed to a server from browse.') + } + + async function installProjectToServer(project: InstallableSearchResult) { + const contentType = getCurrentServerInstallType() + const sid = serverIdQuery.value + const wid = effectiveServerWorldId.value + if (!sid || !wid) { + throw new Error('No server world is available for install.') + } + + if (contentType === 'modpack') { + const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id, { + include_changelog: false, + }) + const versionId = versions[0]?.id ?? project.version_id + if (!versionId) { + throw new Error('No version found for this modpack') + } + + await openServerModpackInstallFlow({ + projectId: project.project_id, + versionId, + name: project.name, + iconUrl: project.icon_url ?? undefined, + }) + return false + } + + const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id, { + include_changelog: false, + }) + const serverLoader = (serverContextServerData.value?.loader ?? '').toLowerCase() + const serverGameVersion = (serverContextServerData.value?.mc_version ?? '').trim() + const compatibleLoaders = getCompatibleLoaders(serverLoader) + + const hasGameVersionMatch = (version: Labrinth.Versions.v2.Version) => + !serverGameVersion || version.game_versions.includes(serverGameVersion) + const hasLoaderMatch = (version: Labrinth.Versions.v2.Version) => { + if (contentType === 'datapack') return true + if (compatibleLoaders.size === 0) return true + return version.loaders.some((loader) => compatibleLoaders.has(normalizeLoader(loader))) + } + + let matchingVersion = versions.find( + (version) => hasGameVersionMatch(version) && hasLoaderMatch(version), + ) + if (!matchingVersion) { + matchingVersion = versions.find((version) => hasLoaderMatch(version)) + } + if (!matchingVersion) { + matchingVersion = versions.find((version) => hasGameVersionMatch(version)) + } + if (!matchingVersion) { + matchingVersion = versions[0] + } + if (!matchingVersion) { + throw new Error('No installable version was found for this project.') + } + + await client.archon.content_v1.addAddon(sid, wid, { + project_id: matchingVersion.project_id, + version_id: matchingVersion.id, + }) + + serverContentProjectIds.value = new Set([...serverContentProjectIds.value, project.project_id]) + return true + } + + function onServerFlowBack() { + serverSetupModalRef.value?.hide() + } + + async function handleServerModpackFlowCreate(config: CreationFlowContextValue) { + const sid = serverIdQuery.value + const wid = effectiveServerWorldId.value + if (!sid || !wid || !config.modpackSelection.value) { + config.loading.value = false + return + } + + try { + await client.archon.content_v1.installContent(sid, wid, { + content_variant: 'modpack', + spec: { + platform: 'modrinth', + project_id: config.modpackSelection.value.projectId, + version_id: config.modpackSelection.value.versionId, + }, + soft_override: false, + properties: config.buildProperties(), + } satisfies Archon.Content.v1.InstallWorldContent) + serverSetupModalRef.value?.hide() + + if (serverFlowFrom.value === 'onboarding') { + await client.archon.servers_v1.endIntro(sid) + await router.push(`/hosting/manage/${sid}/content`) + return + } + + await router.push(`/hosting/manage/${sid}?openSettings=installation`) + } catch (err) { + handleError(err as Error) + config.loading.value = false + } + } + + function markServerProjectInstalled(id: string) { + serverContentProjectIds.value = new Set([...serverContentProjectIds.value, id]) + } + + return { + serverIdQuery, + worldIdQuery, + browseFrom, + serverFlowFrom, + isFromWorlds, + isServerContext, + isSetupServerContext, + effectiveServerWorldId, + serverContextServerData, + serverContentProjectIds, + serverBackUrl, + serverBackLabel, + serverBrowseHeading, + initServerContext, + watchServerContextChanges, + searchServerModpacks, + getServerProjectVersions, + enforceSetupModpackRoute, + installProjectToServer, + onServerFlowBack, + handleServerModpackFlowCreate, + markServerProjectInstalled, + } +} diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js index 2748853d58..841d4a97fc 100644 --- a/apps/app-frontend/src/routes.js +++ b/apps/app-frontend/src/routes.js @@ -43,11 +43,8 @@ export default new createRouter({ children: [ { path: '', - redirect: (to) => { - const rawId = Array.isArray(to.params.id) ? to.params.id[0] : to.params.id - if (!rawId) return '/hosting/manage' - return `/hosting/manage/${encodeURIComponent(rawId)}/content` - }, + name: 'ServerManageOverview', + component: Hosting.Overview, }, { path: 'content', diff --git a/apps/app-frontend/tsconfig.app.json b/apps/app-frontend/tsconfig.app.json index f723e2026f..504558d95f 100644 --- a/apps/app-frontend/tsconfig.app.json +++ b/apps/app-frontend/tsconfig.app.json @@ -3,7 +3,7 @@ "target": "ES2020", "useDefineForClassFields": true, "module": "ESNext", - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2021", "DOM", "DOM.Iterable"], "skipLibCheck": true, "moduleResolution": "bundler", diff --git a/apps/frontend/src/components/ui/servers/ModrinthServersIcon.vue b/apps/frontend/src/components/brand/ModrinthServersIcon.vue similarity index 100% rename from apps/frontend/src/components/ui/servers/ModrinthServersIcon.vue rename to apps/frontend/src/components/brand/ModrinthServersIcon.vue diff --git a/apps/frontend/src/components/ui/servers/notice/AssignNoticeModal.vue b/apps/frontend/src/components/ui/admin/AssignNoticeModal.vue similarity index 100% rename from apps/frontend/src/components/ui/servers/notice/AssignNoticeModal.vue rename to apps/frontend/src/components/ui/admin/AssignNoticeModal.vue diff --git a/apps/frontend/src/components/ui/servers/LoaderSelector.vue b/apps/frontend/src/components/ui/servers/LoaderSelector.vue deleted file mode 100644 index 0998a9ced1..0000000000 --- a/apps/frontend/src/components/ui/servers/LoaderSelector.vue +++ /dev/null @@ -1,96 +0,0 @@ - - - diff --git a/apps/frontend/src/components/ui/servers/LoaderSelectorCard.vue b/apps/frontend/src/components/ui/servers/LoaderSelectorCard.vue deleted file mode 100644 index 0017f1076e..0000000000 --- a/apps/frontend/src/components/ui/servers/LoaderSelectorCard.vue +++ /dev/null @@ -1,68 +0,0 @@ - - - diff --git a/apps/frontend/src/components/ui/servers/LogLine.vue b/apps/frontend/src/components/ui/servers/LogLine.vue deleted file mode 100644 index 07df871a33..0000000000 --- a/apps/frontend/src/components/ui/servers/LogLine.vue +++ /dev/null @@ -1,91 +0,0 @@ - - - - - diff --git a/apps/frontend/src/components/ui/servers/PanelServerActionButton.vue b/apps/frontend/src/components/ui/servers/PanelServerActionButton.vue deleted file mode 100644 index 3022a7b06a..0000000000 --- a/apps/frontend/src/components/ui/servers/PanelServerActionButton.vue +++ /dev/null @@ -1,276 +0,0 @@ - - - diff --git a/apps/frontend/src/components/ui/servers/PanelServerStatus.vue b/apps/frontend/src/components/ui/servers/PanelServerStatus.vue deleted file mode 100644 index f5b42fa0e1..0000000000 --- a/apps/frontend/src/components/ui/servers/PanelServerStatus.vue +++ /dev/null @@ -1,75 +0,0 @@ - - - diff --git a/apps/frontend/src/components/ui/servers/PanelSpinner.vue b/apps/frontend/src/components/ui/servers/PanelSpinner.vue deleted file mode 100644 index c2c7f55eab..0000000000 --- a/apps/frontend/src/components/ui/servers/PanelSpinner.vue +++ /dev/null @@ -1,22 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/PanelTerminal.vue b/apps/frontend/src/components/ui/servers/PanelTerminal.vue deleted file mode 100644 index 950a2dc9ea..0000000000 --- a/apps/frontend/src/components/ui/servers/PanelTerminal.vue +++ /dev/null @@ -1,1414 +0,0 @@ - - - - - diff --git a/apps/frontend/src/components/ui/servers/PlatformChangeModpackVersionModal.vue b/apps/frontend/src/components/ui/servers/PlatformChangeModpackVersionModal.vue deleted file mode 100644 index e7b0c74ed7..0000000000 --- a/apps/frontend/src/components/ui/servers/PlatformChangeModpackVersionModal.vue +++ /dev/null @@ -1,163 +0,0 @@ - - - diff --git a/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue b/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue deleted file mode 100644 index f4ca68f7c0..0000000000 --- a/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue +++ /dev/null @@ -1,538 +0,0 @@ - - - diff --git a/apps/frontend/src/components/ui/servers/SaveBanner.vue b/apps/frontend/src/components/ui/servers/SaveBanner.vue deleted file mode 100644 index a43e4dd1f1..0000000000 --- a/apps/frontend/src/components/ui/servers/SaveBanner.vue +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/apps/frontend/src/components/ui/servers/ServerSidebar.vue b/apps/frontend/src/components/ui/servers/ServerSidebar.vue deleted file mode 100644 index 1b455df0c1..0000000000 --- a/apps/frontend/src/components/ui/servers/ServerSidebar.vue +++ /dev/null @@ -1,52 +0,0 @@ - - - diff --git a/apps/frontend/src/components/ui/servers/TeleportOverflowMenu.vue b/apps/frontend/src/components/ui/servers/TeleportOverflowMenu.vue deleted file mode 100644 index 4a8f774134..0000000000 --- a/apps/frontend/src/components/ui/servers/TeleportOverflowMenu.vue +++ /dev/null @@ -1,438 +0,0 @@ - - - diff --git a/apps/frontend/src/components/ui/servers/icons/ChevronDownIcon.vue b/apps/frontend/src/components/ui/servers/icons/ChevronDownIcon.vue deleted file mode 100644 index 783d18a402..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/ChevronDownIcon.vue +++ /dev/null @@ -1,16 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/ChevronUpIcon.vue b/apps/frontend/src/components/ui/servers/icons/ChevronUpIcon.vue deleted file mode 100644 index da6cf408ab..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/ChevronUpIcon.vue +++ /dev/null @@ -1,16 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/CodeFileIcon.vue b/apps/frontend/src/components/ui/servers/icons/CodeFileIcon.vue deleted file mode 100644 index 42022a33ce..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/CodeFileIcon.vue +++ /dev/null @@ -1,18 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/CogFolderIcon.vue b/apps/frontend/src/components/ui/servers/icons/CogFolderIcon.vue deleted file mode 100644 index cc8fc1bc66..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/CogFolderIcon.vue +++ /dev/null @@ -1,26 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/EarthIcon.vue b/apps/frontend/src/components/ui/servers/icons/EarthIcon.vue deleted file mode 100644 index e96f944bcc..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/EarthIcon.vue +++ /dev/null @@ -1,20 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/FullscreenIcon.vue b/apps/frontend/src/components/ui/servers/icons/FullscreenIcon.vue deleted file mode 100644 index 383912d2b8..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/FullscreenIcon.vue +++ /dev/null @@ -1,19 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/ImageFileIcon.vue b/apps/frontend/src/components/ui/servers/icons/ImageFileIcon.vue deleted file mode 100644 index 6aa81c2779..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/ImageFileIcon.vue +++ /dev/null @@ -1,18 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/LoaderIcon.vue b/apps/frontend/src/components/ui/servers/icons/LoaderIcon.vue deleted file mode 100644 index 090bb945bd..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/LoaderIcon.vue +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/apps/frontend/src/components/ui/servers/icons/LoadingIcon.vue b/apps/frontend/src/components/ui/servers/icons/LoadingIcon.vue deleted file mode 100644 index 9e9a8ad3fa..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/LoadingIcon.vue +++ /dev/null @@ -1,9 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/MinimizeIcon.vue.vue b/apps/frontend/src/components/ui/servers/icons/MinimizeIcon.vue.vue deleted file mode 100644 index 27b0fcad21..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/MinimizeIcon.vue.vue +++ /dev/null @@ -1,19 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/PanelErrorIcon.vue b/apps/frontend/src/components/ui/servers/icons/PanelErrorIcon.vue deleted file mode 100644 index 2ecad74dce..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/PanelErrorIcon.vue +++ /dev/null @@ -1,16 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/SlashIcon.vue b/apps/frontend/src/components/ui/servers/icons/SlashIcon.vue deleted file mode 100644 index 7f6e62fca2..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/SlashIcon.vue +++ /dev/null @@ -1,10 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/TextFileIcon.vue b/apps/frontend/src/components/ui/servers/icons/TextFileIcon.vue deleted file mode 100644 index 99bcee1ac1..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/TextFileIcon.vue +++ /dev/null @@ -1,20 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/icons/Timer.vue b/apps/frontend/src/components/ui/servers/icons/Timer.vue deleted file mode 100644 index e1ead004b1..0000000000 --- a/apps/frontend/src/components/ui/servers/icons/Timer.vue +++ /dev/null @@ -1,17 +0,0 @@ - diff --git a/apps/frontend/src/components/ui/servers/notice/NoticeDashboardItem.vue b/apps/frontend/src/components/ui/servers/notice/NoticeDashboardItem.vue deleted file mode 100644 index 2208808456..0000000000 --- a/apps/frontend/src/components/ui/servers/notice/NoticeDashboardItem.vue +++ /dev/null @@ -1,127 +0,0 @@ - - diff --git a/apps/frontend/src/components/ui/thread/ThreadView.vue b/apps/frontend/src/components/ui/thread/ThreadView.vue index 608f0f1f10..aca57b1961 100644 --- a/apps/frontend/src/components/ui/thread/ThreadView.vue +++ b/apps/frontend/src/components/ui/thread/ThreadView.vue @@ -90,7 +90,7 @@ import dayjs from 'dayjs' import { useImageUpload } from '~/composables/image-upload.ts' import { isStaff } from '~/helpers/users.js' -import ChevronDownIcon from '../servers/icons/ChevronDownIcon.vue' +import { ChevronDownIcon } from '@modrinth/assets' import ThreadMessage from './ThreadMessage.vue' const { addNotification } = injectNotificationManager() diff --git a/apps/frontend/src/layouts/default.vue b/apps/frontend/src/layouts/default.vue index 6ef59f08c5..5e0be1ecec 100644 --- a/apps/frontend/src/layouts/default.vue +++ b/apps/frontend/src/layouts/default.vue @@ -760,7 +760,7 @@ import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal. import OrganizationCreateModal from '~/components/ui/create/OrganizationCreateModal.vue' import ProjectCreateModal from '~/components/ui/create/ProjectCreateModal.vue' import ModrinthFooter from '~/components/ui/ModrinthFooter.vue' -import TeleportOverflowMenu from '~/components/ui/servers/TeleportOverflowMenu.vue' +import TeleportOverflowMenu from '@modrinth/ui/src/components/base/TeleportOverflowMenu.vue' import { errors as generatedStateErrors } from '~/generated/state.json' import { getProjectTypeMessage } from '~/utils/i18n-project-type.ts' diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json index 3e1ca2bb57..b2b238d30c 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -2777,18 +2777,6 @@ "search.filter.locked.server.sync": { "message": "Sync with server" }, - "servers.busy.backup-creating": { - "message": "Backup creation in progress" - }, - "servers.busy.backup-restoring": { - "message": "Backup restore in progress" - }, - "servers.busy.installing": { - "message": "Server is installing" - }, - "servers.busy.syncing-content": { - "message": "Content sync in progress" - }, "servers.notice.actions": { "message": "Actions" }, diff --git a/apps/frontend/src/pages/admin/billing/[id].vue b/apps/frontend/src/pages/admin/billing/[id].vue index 7549d7a004..8b00a8feae 100644 --- a/apps/frontend/src/pages/admin/billing/[id].vue +++ b/apps/frontend/src/pages/admin/billing/[id].vue @@ -343,7 +343,7 @@ import { DEFAULT_CREDIT_EMAIL_MESSAGE } from '@modrinth/utils/utils.ts' import { useQuery } from '@tanstack/vue-query' import dayjs from 'dayjs' -import ModrinthServersIcon from '~/components/ui/servers/ModrinthServersIcon.vue' +import ModrinthServersIcon from '~/components/brand/ModrinthServersIcon.vue' const { addNotification } = injectNotificationManager() const { labrinth } = injectModrinthClient() diff --git a/apps/frontend/src/pages/admin/servers/notices.vue b/apps/frontend/src/pages/admin/servers/notices.vue index 1ad5b0712a..9e5536a256 100644 --- a/apps/frontend/src/pages/admin/servers/notices.vue +++ b/apps/frontend/src/pages/admin/servers/notices.vue @@ -282,7 +282,7 @@ import type { ServerNotice as ServerNoticeType } from '@modrinth/utils' import dayjs from 'dayjs' import { computed } from 'vue' -import AssignNoticeModal from '~/components/ui/servers/notice/AssignNoticeModal.vue' +import AssignNoticeModal from '~/components/ui/admin/AssignNoticeModal.vue' import { useServersFetch } from '~/composables/servers/servers-fetch.ts' const { addNotification } = injectNotificationManager() diff --git a/apps/frontend/src/pages/discover/[type]/index.vue b/apps/frontend/src/pages/discover/[type]/index.vue index 25fee7a0a3..f28b0ed87d 100644 --- a/apps/frontend/src/pages/discover/[type]/index.vue +++ b/apps/frontend/src/pages/discover/[type]/index.vue @@ -653,7 +653,7 @@ const serverBackUrl = computed(() => { if (!serverData.value) return '' const id = serverData.value.server_id if (fromContext.value === 'onboarding') return `/hosting/manage/${id}?resumeModal=setup-type` - if (fromContext.value === 'reset-server') return `/hosting/manage/${id}/options/loader` + if (fromContext.value === 'reset-server') return `/hosting/manage/${id}?openSettings=installation` return `/hosting/manage/${id}/content` }) @@ -692,7 +692,7 @@ async function onModpackFlowCreate(config: CreationFlowContextValue) { queryClient.invalidateQueries({ queryKey: ['servers', 'detail', currentServerId.value] }) navigateTo(`/hosting/manage/${currentServerId.value}/content`) } else { - navigateTo(`/hosting/manage/${currentServerId.value}/options/loader`) + navigateTo(`/hosting/manage/${currentServerId.value}?openSettings=installation`) } } catch (e) { handleError(new Error(`Error installing modpack: ${e}`)) diff --git a/apps/frontend/src/pages/hosting/index.vue b/apps/frontend/src/pages/hosting/index.vue index 2f055d3dbe..b2743a985c 100644 --- a/apps/frontend/src/pages/hosting/index.vue +++ b/apps/frontend/src/pages/hosting/index.vue @@ -652,7 +652,7 @@ import { useQuery } from '@tanstack/vue-query' import { computed } from 'vue' import OptionGroup from '~/components/ui/OptionGroup.vue' -import LoaderIcon from '~/components/ui/servers/icons/LoaderIcon.vue' +import { LoaderIcon } from '@modrinth/ui/src/components/servers/icons' import MedalPlanPromotion from '~/components/ui/servers/marketing/MedalPlanPromotion.vue' import ServerPlanSelector from '~/components/ui/servers/marketing/ServerPlanSelector.vue' import { products } from '~/generated/state.json' diff --git a/apps/frontend/src/pages/hosting/manage/[id].vue b/apps/frontend/src/pages/hosting/manage/[id].vue index abc00180c8..55d6eb7a4b 100644 --- a/apps/frontend/src/pages/hosting/manage/[id].vue +++ b/apps/frontend/src/pages/hosting/manage/[id].vue @@ -1,1539 +1,72 @@ diff --git a/apps/frontend/src/components/ui/servers/marketing/MedalServerCountdown.vue b/packages/ui/src/components/servers/marketing/MedalServerCountdown.vue similarity index 80% rename from apps/frontend/src/components/ui/servers/marketing/MedalServerCountdown.vue rename to packages/ui/src/components/servers/marketing/MedalServerCountdown.vue index 906a7a3d94..e219c20ea2 100644 --- a/apps/frontend/src/components/ui/servers/marketing/MedalServerCountdown.vue +++ b/packages/ui/src/components/servers/marketing/MedalServerCountdown.vue @@ -34,18 +34,27 @@ - + diff --git a/packages/ui/src/components/servers/server-header/PanelServerActionButton.vue b/packages/ui/src/components/servers/server-header/PanelServerActionButton.vue new file mode 100644 index 0000000000..fe6d61f121 --- /dev/null +++ b/packages/ui/src/components/servers/server-header/PanelServerActionButton.vue @@ -0,0 +1,84 @@ + + + diff --git a/packages/ui/src/components/servers/server-header/PanelServerOverflowMenu.vue b/packages/ui/src/components/servers/server-header/PanelServerOverflowMenu.vue new file mode 100644 index 0000000000..e8a4f8d1ee --- /dev/null +++ b/packages/ui/src/components/servers/server-header/PanelServerOverflowMenu.vue @@ -0,0 +1,145 @@ + + + diff --git a/packages/ui/src/components/servers/server-header/ServerManageHeader.vue b/packages/ui/src/components/servers/server-header/ServerManageHeader.vue new file mode 100644 index 0000000000..40dbea6081 --- /dev/null +++ b/packages/ui/src/components/servers/server-header/ServerManageHeader.vue @@ -0,0 +1,162 @@ + + + diff --git a/packages/ui/src/components/servers/server-header/index.ts b/packages/ui/src/components/servers/server-header/index.ts new file mode 100644 index 0000000000..97bcdcc7f2 --- /dev/null +++ b/packages/ui/src/components/servers/server-header/index.ts @@ -0,0 +1,3 @@ +export { default as PanelServerActionButton } from './PanelServerActionButton.vue' +export { default as PanelServerOverflowMenu } from './PanelServerOverflowMenu.vue' +export { default as ServerManageHeader } from './ServerManageHeader.vue' diff --git a/packages/ui/src/components/servers/server-header/use-server-power-action.ts b/packages/ui/src/components/servers/server-header/use-server-power-action.ts new file mode 100644 index 0000000000..f66e5da61d --- /dev/null +++ b/packages/ui/src/components/servers/server-header/use-server-power-action.ts @@ -0,0 +1,136 @@ +import { useStorage } from '@vueuse/core' +import { computed, type Ref, ref } from 'vue' + +import { useVIntl } from '#ui/composables/i18n' +import { + injectModrinthClient, + injectModrinthServerContext, + injectNotificationManager, +} from '#ui/providers' + +export type PowerAction = 'Start' | 'Stop' | 'Restart' | 'Kill' + +export type PanelActionConfirmModalController = { + show: () => void + hide: () => void +} + +export function useServerPowerAction(options?: { + disabled?: Ref + confirmModalRef?: Ref +}) { + const { formatMessage } = useVIntl() + const client = injectModrinthClient() + const { serverId, server, powerState, busyReasons } = injectModrinthServerContext() + const { addNotification } = injectNotificationManager() + const pendingAction = ref(null) + const dontAskAgain = ref(false) + + const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, { + powerDontAskAgain: false, + }) + + const isInstalling = computed(() => server.value.status === 'installing') + const isRunning = computed(() => powerState.value === 'running') + const isStopping = computed(() => powerState.value === 'stopping') + const isTransitioning = computed( + () => powerState.value === 'starting' || powerState.value === 'stopping', + ) + const showStopButton = computed(() => isRunning.value || isStopping.value) + + const busyTooltip = computed(() => + busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined, + ) + + const canTakeAction = computed( + () => !isTransitioning.value && !options?.disabled?.value && busyReasons.value.length === 0, + ) + + const primaryActionText = computed(() => { + switch (powerState.value) { + case 'starting': + return 'Starting...' + case 'stopping': + return 'Stopping...' + case 'running': + return 'Restart' + default: + return 'Start' + } + }) + + async function sendPowerAction(action: PowerAction) { + try { + await client.archon.servers_v0.power(serverId, action) + } catch (error) { + console.error(`Error performing ${action} on server:`, error) + addNotification({ + type: 'error', + title: `Failed to ${action.toLowerCase()} server`, + text: 'An error occurred while performing this action.', + }) + } + } + + function initiateAction(action: PowerAction) { + if (!canTakeAction.value) return + + if (action === 'Start') { + void sendPowerAction(action) + return + } + + pendingAction.value = action + + if (userPreferences.value.powerDontAskAgain) { + executePendingAction() + } else { + options?.confirmModalRef?.value?.show() + } + } + + function handlePrimaryAction() { + initiateAction(isRunning.value ? 'Restart' : 'Start') + } + + function executePendingAction() { + if (!pendingAction.value) return + + if (!canTakeAction.value) { + resetPendingAction() + return + } + + void sendPowerAction(pendingAction.value) + + if (dontAskAgain.value) { + userPreferences.value.powerDontAskAgain = true + } + + resetPendingAction() + } + + function resetPendingAction() { + options?.confirmModalRef?.value?.hide() + pendingAction.value = null + dontAskAgain.value = false + } + + return { + isInstalling, + isRunning, + isStopping, + isTransitioning, + showStopButton, + busyTooltip, + canTakeAction, + primaryActionText, + pendingAction, + dontAskAgain, + sendPowerAction, + initiateAction, + handlePrimaryAction, + executePendingAction, + resetPendingAction, + } +} diff --git a/packages/ui/src/composables/index.ts b/packages/ui/src/composables/index.ts index eda671a137..c5afc1d095 100644 --- a/packages/ui/src/composables/index.ts +++ b/packages/ui/src/composables/index.ts @@ -9,6 +9,10 @@ export * from './i18n-debug' export * from './page-leave-safety' export * from './scroll-indicator' export * from './server-backup' +export * from './server-console' +export * from './server-manage-core-runtime' export * from './sticky-observer' export * from './terminal' +export * from './use-server-image' +export * from './use-server-project' export * from './virtual-scroll' diff --git a/packages/ui/src/composables/server-console.ts b/packages/ui/src/composables/server-console.ts new file mode 100644 index 0000000000..a58d926823 --- /dev/null +++ b/packages/ui/src/composables/server-console.ts @@ -0,0 +1,120 @@ +import { createGlobalState } from '@vueuse/core' +import { type Ref, shallowRef } from 'vue' + +const maxLines = 10000 +const batchTimeout = 300 +const initialBatchSize = 256 + +export const useModrinthServersConsole = createGlobalState(() => { + const output: Ref = shallowRef([]) + const searchQuery: Ref = shallowRef('') + const filteredOutput: Ref = shallowRef([]) + let searchRegex: RegExp | null = null + + let lineBuffer: string[] = [] + let batchTimer: NodeJS.Timeout | null = null + let isProcessingInitialBatch = false + + let refilterTimer: NodeJS.Timeout | null = null + const refilterTimeout = 100 + + const updateFilter = () => { + if (!searchQuery.value) { + filteredOutput.value = [] + return + } + + if (!searchRegex) { + searchRegex = new RegExp(searchQuery.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') + } + + filteredOutput.value = output.value.filter((line) => searchRegex?.test(line) ?? false) + } + + const scheduleRefilter = () => { + if (refilterTimer) clearTimeout(refilterTimer) + refilterTimer = setTimeout(updateFilter, refilterTimeout) + } + + const flushBuffer = () => { + if (lineBuffer.length === 0) return + + const processedLines = lineBuffer.flatMap((line) => line.split('\n').filter(Boolean)) + + if (isProcessingInitialBatch && processedLines.length >= initialBatchSize) { + isProcessingInitialBatch = false + output.value = processedLines.slice(-maxLines) + } else { + const newOutput = [...output.value, ...processedLines] + output.value = newOutput.slice(-maxLines) + } + + lineBuffer = [] + batchTimer = null + + if (searchQuery.value) { + scheduleRefilter() + } + } + + const addLine = (line: string): void => { + lineBuffer.push(line) + + if (!batchTimer) { + batchTimer = setTimeout(flushBuffer, batchTimeout) + } + } + + const addLines = (lines: string[]): void => { + if (output.value.length === 0 && lines.length >= initialBatchSize) { + isProcessingInitialBatch = true + lineBuffer = lines + flushBuffer() + return + } + + lineBuffer.push(...lines) + + if (!batchTimer) { + batchTimer = setTimeout(flushBuffer, batchTimeout) + } + } + + const setSearchQuery = (query: string): void => { + searchQuery.value = query + searchRegex = null + updateFilter() + } + + const clear = (): void => { + output.value = [] + filteredOutput.value = [] + searchQuery.value = '' + lineBuffer = [] + isProcessingInitialBatch = false + if (batchTimer) { + clearTimeout(batchTimer) + batchTimer = null + } + if (refilterTimer) { + clearTimeout(refilterTimer) + refilterTimer = null + } + searchRegex = null + } + + const findLineIndex = (line: string): number => { + return output.value.findIndex((l) => l === line) + } + + return { + output, + searchQuery, + filteredOutput, + addLine, + addLines, + setSearchQuery, + clear, + findLineIndex, + } +}) diff --git a/packages/ui/src/composables/server-manage-core-runtime.ts b/packages/ui/src/composables/server-manage-core-runtime.ts new file mode 100644 index 0000000000..46a0545030 --- /dev/null +++ b/packages/ui/src/composables/server-manage-core-runtime.ts @@ -0,0 +1,391 @@ +import { type Archon, clearNodeAuthState, setNodeAuthState } from '@modrinth/api-client' +import type { Stats } from '@modrinth/utils' +import type { ComputedRef, Ref } from 'vue' +import { computed, reactive, ref } from 'vue' + +import { injectModrinthClient, provideModrinthServerContext } from '../providers' +import type { BusyReason } from '../providers/server-context' +import { defineMessage } from './i18n' +import { useModrinthServersConsole } from './server-console' + +type ReadableRef = Ref | ComputedRef +type SocketUnsubscriber = () => void + +type ConnectSocketOptions = { + force?: boolean + extraSubscriptions?: (targetServerId: string) => SocketUnsubscriber[] +} + +type UseServerManageCoreRuntimeOptions = { + serverId: ReadableRef + worldId: ReadableRef + server: ReadableRef + isSyncingContent: ReadableRef + markBackupCancelled?: (backupId: string) => void + includeBackupBusyReasons?: boolean + setDisconnectedOnAuthIncorrect?: boolean + syncUptimeFromState?: boolean + incrementUptimeLocally?: boolean + eventGuard?: () => boolean + onStateEvent?: (data: Archon.Websocket.v0.WSStateEvent) => void +} + +const initialConsoleMessage = [ + ' __________________________________________________', + ' / Welcome to your \x1B[32mModrinth Server\x1B[37m! \\', + '| Press the green start button to start your server! |', + ' \\____________________________________________________/', + '\x1B[32m _ _ \x1B[37m', + '\x1B[32m (o)--(o) \x1B[37m', + '\x1B[32m /.______.\\\x1B[37m', + '\x1B[32m \\________/ \x1B[37m', + '\x1B[32m ./ \\. \x1B[37m', + '\x1B[32m ( . , )\x1B[37m', + '\x1B[32m \\ \\_\\\\ //_/ /\x1B[37m', + '\x1B[32m ~~ ~~ ~~\x1B[37m', +] + +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 mapPowerStateFromStateEvent = ( + data: Archon.Websocket.v0.WSStateEvent, +): Archon.Websocket.v0.PowerState => { + const powerMap: Record = + { + 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('stopped') + const powerStateDetails = ref<{ oom_killed?: boolean; exit_code?: number }>() + const isServerRunning = computed(() => serverPowerState.value === 'running') + const stats = ref(createInitialStats()) + const uptimeSeconds = ref(0) + const backupsState = reactive(new Map()) + const fsAuth = ref<{ url: string; token: string } | null>(null) + const fsOps = ref([]) + const fsQueuedOps = ref([]) + const connectedSocketServerId = ref(null) + const socketUnsubscribers = ref([]) + const cpuData = ref([]) + const ramData = ref([]) + + let uptimeIntervalId: ReturnType | null = null + + const markBackupCancelled = + options.markBackupCancelled ?? + ((backupId: string) => { + backupsState.delete(backupId) + }) + + const busyReasons = computed(() => { + 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.includeBackupBusyReasons) { + for (const entry of backupsState.values()) { + if (entry.create?.state === 'ongoing') { + reasons.push({ + reason: defineMessage({ + id: 'servers.busy.backup-creating', + defaultMessage: 'Backup creation in progress', + }), + }) + break + } + if (entry.restore?.state === 'ongoing') { + reasons.push({ + reason: defineMessage({ + id: 'servers.busy.backup-restoring', + defaultMessage: 'Backup restore in progress', + }), + }) + break + } + } + } + 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 + stats.value = { + current: currentStats, + past: { ...stats.value.current }, + graph: { + cpu: appendGraphData(cpuData.value, currentStats.cpu_percent), + ram: appendGraphData( + ramData.value, + Math.floor((currentStats.ram_usage_bytes / currentStats.ram_total_bytes) * 100), + ), + }, + } + } + + 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 + const log = data.message.split('\n').filter((line) => line.trim()) + modrinthServersConsole.addLines(log) + } + + const handleStats = (data: Archon.Websocket.v0.WSStatsEvent) => { + updateStats({ + cpu_percent: data.cpu_percent, + ram_usage_bytes: data.ram_usage_bytes, + ram_total_bytes: data.ram_total_bytes, + storage_usage_bytes: data.storage_usage_bytes, + storage_total_bytes: data.storage_total_bytes, + }) + } + + 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) => { + clearSocketListeners() + + if (targetServerId) { + client.archon.sockets.disconnect(targetServerId) + } + + stopUptimeTicker() + 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 => { + 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() + for (const line of initialConsoleMessage) { + modrinthServersConsole.addLine(line) + } + + const baseSubscriptions: SocketUnsubscriber[] = [ + client.archon.sockets.on(targetServerId, 'log', handleLog), + 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 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, + server: options.server as Ref, + isConnected, + isWsAuthIncorrect, + powerState: serverPowerState, + powerStateDetails, + isServerRunning, + stats, + uptimeSeconds, + backupsState, + markBackupCancelled, + isSyncingContent: options.isSyncingContent as Ref, + busyReasons, + fsAuth, + fsOps, + fsQueuedOps, + refreshFsAuth, + }) + + setNodeAuthState(() => fsAuth.value, refreshFsAuth) + + const cleanupCoreRuntime = (targetServerId?: string) => { + disconnectSocket(targetServerId ?? connectedSocketServerId.value ?? undefined) + clearNodeAuthState() + } + + return { + backupsState, + busyReasons, + cleanupCoreRuntime, + connectSocket, + connectedSocketServerId, + disconnectSocket, + fsAuth, + fsOps, + fsQueuedOps, + isConnected, + isServerRunning, + isWsAuthIncorrect, + powerStateDetails, + refreshFsAuth, + serverPowerState, + stats, + uptimeSeconds, + } +} diff --git a/apps/frontend/src/composables/servers/use-server-image.ts b/packages/ui/src/composables/use-server-image.ts similarity index 75% rename from apps/frontend/src/composables/servers/use-server-image.ts rename to packages/ui/src/composables/use-server-image.ts index c613a79ae5..6e87e6b5e2 100644 --- a/apps/frontend/src/composables/servers/use-server-image.ts +++ b/packages/ui/src/composables/use-server-image.ts @@ -1,7 +1,10 @@ import type { Archon } from '@modrinth/api-client' -import { injectModrinthClient } from '@modrinth/ui' import { type ComputedRef, ref, watch } from 'vue' +import { injectModrinthClient } from '#ui/providers' + +const imageCache = new Map() + // TODO: Remove and use V1 when available export function useServerImage( serverId: string, @@ -10,23 +13,23 @@ export function useServerImage( const client = injectModrinthClient() const image = ref() - const sharedImage = useState(`server-icon-${serverId}`) - if (sharedImage.value) { - image.value = sharedImage.value + const cached = imageCache.get(serverId) + if (cached) { + image.value = cached } async function loadImage() { - if (sharedImage.value) { - image.value = sharedImage.value + if (typeof window === 'undefined') return + + if (imageCache.has(serverId)) { + image.value = imageCache.get(serverId) return } - if (import.meta.server) return - - const cached = localStorage.getItem(`server-icon-${serverId}`) - if (cached) { - sharedImage.value = cached - image.value = cached + const localCached = localStorage.getItem(`server-icon-${serverId}`) + if (localCached) { + imageCache.set(serverId, localCached) + image.value = localCached return } @@ -34,9 +37,7 @@ export function useServerImage( const upstreamVal = upstream.value if (upstreamVal?.project_id) { try { - const project = await $fetch<{ icon_url?: string }>( - `https://api.modrinth.com/v2/project/${upstreamVal.project_id}`, - ) + const project = await client.labrinth.projects_v2.get(upstreamVal.project_id) projectIconUrl = project.icon_url } catch { // project fetch failed, continue without icon url @@ -48,18 +49,19 @@ export function useServerImage( if (fileData instanceof Blob) { const dataURL = await resizeImage(fileData, 512) - sharedImage.value = dataURL + imageCache.set(serverId, dataURL) localStorage.setItem(`server-icon-${serverId}`, dataURL) image.value = dataURL return } - } catch (error: any) { - if (error?.statusCode >= 500) { + } catch (error: unknown) { + const statusCode = (error as { statusCode?: number })?.statusCode + if (statusCode != null && statusCode >= 500) { image.value = undefined return } - if (error?.statusCode === 404 && projectIconUrl) { + if (statusCode === 404 && projectIconUrl) { try { const response = await fetch(projectIconUrl) if (!response.ok) throw new Error('Failed to fetch icon') @@ -90,7 +92,7 @@ export function useServerImage( } }, 'image/png') const result = canvas.toDataURL('image/png') - sharedImage.value = result + imageCache.set(serverId, result) localStorage.setItem(`server-icon-${serverId}`, result) resolve(result) URL.revokeObjectURL(img.src) @@ -99,8 +101,8 @@ export function useServerImage( }) image.value = dataURL return - } catch (externalError: any) { - console.debug('Could not process external icon:', externalError.message) + } catch (externalError: unknown) { + console.debug('Could not process external icon:', (externalError as Error).message) } } } diff --git a/apps/frontend/src/composables/servers/use-server-project.ts b/packages/ui/src/composables/use-server-project.ts similarity index 70% rename from apps/frontend/src/composables/servers/use-server-project.ts rename to packages/ui/src/composables/use-server-project.ts index eeb22a8b52..524bf45265 100644 --- a/apps/frontend/src/composables/servers/use-server-project.ts +++ b/packages/ui/src/composables/use-server-project.ts @@ -1,17 +1,18 @@ import type { Archon } from '@modrinth/api-client' -import type { Project } from '@modrinth/utils' import { useQuery } from '@tanstack/vue-query' -import { $fetch } from 'ofetch' import { computed, type ComputedRef } from 'vue' +import { injectModrinthClient } from '#ui/providers' + // TODO: Remove and use v1 export function useServerProject( upstream: ComputedRef, ) { + const client = injectModrinthClient() + return useQuery({ queryKey: computed(() => ['servers', 'project', upstream.value?.project_id ?? null]), - queryFn: () => - $fetch(`https://api.modrinth.com/v2/project/${upstream.value!.project_id}`), + queryFn: () => client.labrinth.projects_v2.get(upstream.value!.project_id!), enabled: computed(() => !!upstream.value?.project_id), }) } diff --git a/packages/ui/src/layouts/shared/server-settings/pages/installation.vue b/packages/ui/src/layouts/shared/server-settings/pages/installation.vue index ea8ac8b096..c235d73a3e 100644 --- a/packages/ui/src/layouts/shared/server-settings/pages/installation.vue +++ b/packages/ui/src/layouts/shared/server-settings/pages/installation.vue @@ -29,11 +29,15 @@ @@ -167,7 +171,7 @@ const messages = defineMessages({ }) const emit = defineEmits<{ - reinstall: [any?] + reinstall: [unknown?] 'reinstall-failed': [] }>() @@ -766,7 +770,7 @@ watch( }, ) -function onReinstall(event?: any) { +function onReinstall(event?: unknown) { installationSettingsLayout.value?.cancelEditing() emit('reinstall', event) } diff --git a/packages/ui/src/layouts/shared/server-settings/pages/properties.vue b/packages/ui/src/layouts/shared/server-settings/pages/properties.vue index 1e77179214..bf0077498d 100644 --- a/packages/ui/src/layouts/shared/server-settings/pages/properties.vue +++ b/packages/ui/src/layouts/shared/server-settings/pages/properties.vue @@ -1,5 +1,5 @@