feat: backups page cleanup before worlds (#5844)

* feat: card alignment + fix modals

* feat: change admon title in restore alert modal

* fix: lint

* feat: backups queue api into api-client

* feat: impl backup queue api endpoints into frontend

* feat: ack fix

* feat: bulk actions

* feat: bulk delete impl

* fix: lint

* fix: align error states

* fix: transition group

* feat: ready for qa

* fix: lint

* feat: qa

* feat: stacked admonitions component

* fix: issues with stacking

* feat: hook up admonition stacking + fix app csp for staging kyros nodes

* fix: logs.vue

* qa: close stack on admonitions click

* fix: all problems with stacked admonitions

* qa: admonition cleanup and copy overhaul draft

* fix: qa issues padding

* fix: padding bug

* feat: qa

* fix: intercom in app csp bug

* fix: positioning intercom

* feat: loading overlay on top of console + admon consistency changes

* feat: scroll indicator fade in backup delete modal + admon timestamp fix

* feat: move action bar behind modal

* fix: lint + i18n

* fix: server ping spam on filter (cache but clear on unmount)

* fix: 1 admon fade in flicker issue

* chore: temp staging undo

* qa: changes

* fix: lint

* chore: revert staging to use staging

* fix: scoping
This commit is contained in:
Calum H.
2026-04-27 20:03:48 +01:00
committed by GitHub
parent 85ae1f2074
commit 620894aecb
79 changed files with 4640 additions and 1656 deletions

View File

@@ -1,4 +1,5 @@
<script setup>
import { Intercom, shutdown as shutdownIntercom } from '@intercom/messenger-js-sdk'
import {
AuthFeature,
NodeAuthFeature,
@@ -238,6 +239,7 @@ onMounted(async () => {
onUnmounted(async () => {
document.querySelector('body').removeEventListener('click', handleClick)
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
shutdownHostingIntercom()
await unlistenUpdateDownload?.()
})
@@ -652,6 +654,102 @@ const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value
const showAd = computed(
() => sidebarVisible.value && !hasPlus.value && credentials.value !== undefined,
)
const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
const INTERCOM_DEFAULT_PADDING = 20
const INTERCOM_APP_SIDEBAR_WIDTH = 300
let intercomBooting = false
let intercomBooted = false
async function fetchIntercomToken() {
const creds = await getCreds()
if (!creds?.session) {
throw new Error('Not authenticated')
}
const params = new URLSearchParams()
if (route.path.startsWith('/hosting/manage/') && typeof route.params.id === 'string') {
params.set('server_id', route.params.id)
}
const query = params.size > 0 ? `?${params.toString()}` : ''
const response = await tauriFetch(`${config.siteUrl}/api/intercom/messenger-jwt${query}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${creds.session}`,
},
})
if (!response.ok) {
throw new Error(`Failed to fetch Intercom token: ${response.status}`)
}
return await response.json()
}
async function bootIntercom() {
if (
intercomBooting ||
intercomBooted ||
!hostingRouteActive.value ||
!credentials.value?.session
) {
return
}
intercomBooting = true
console.debug('[APP][INTERCOM] initializing secure support chat')
try {
const { token } = await fetchIntercomToken()
Intercom({
app_id: 'ykeritl9',
intercom_user_jwt: token,
session_duration: 1000 * 60 * 60 * 24,
alignment: 'right',
horizontal_padding: sidebarVisible.value
? INTERCOM_APP_SIDEBAR_WIDTH + INTERCOM_DEFAULT_PADDING
: INTERCOM_DEFAULT_PADDING,
vertical_padding: INTERCOM_DEFAULT_PADDING,
})
intercomBooted = true
} catch (error) {
console.warn('[APP][INTERCOM] failed to initialize secure support chat', error)
} finally {
intercomBooting = false
}
}
function shutdownHostingIntercom() {
if (!intercomBooted && !intercomBooting) return
shutdownIntercom()
intercomBooting = false
intercomBooted = false
}
watch(
sidebarVisible,
(visible) => {
if (intercomBooted) {
window.Intercom?.('update', {
horizontal_padding: visible
? INTERCOM_APP_SIDEBAR_WIDTH + INTERCOM_DEFAULT_PADDING
: INTERCOM_DEFAULT_PADDING,
vertical_padding: INTERCOM_DEFAULT_PADDING,
})
}
},
{ immediate: true },
)
watch(
[hostingRouteActive, credentials],
([active]) => {
if (active) {
void bootIntercom()
} else {
shutdownHostingIntercom()
}
},
{ immediate: true },
)
watch(showAd, () => {
if (!showAd.value) {

View File

@@ -180,13 +180,6 @@ img {
}
}
button,
input[type='button'] {
cursor: pointer;
border: none;
outline: 2px solid transparent;
}
@import '@modrinth/assets/omorphia.scss';
input {

View File

@@ -30,10 +30,10 @@
"message": "Discover servers"
},
"app.browse.hide-added-servers": {
"message": "Hide added servers"
"message": "Hide already added servers"
},
"app.browse.hide-installed-content": {
"message": "Hide installed content"
"message": "Hide already installed content"
},
"app.browse.install-content-to-instance": {
"message": "Install content to instance"

View File

@@ -127,6 +127,8 @@ const instance: Ref<Instance | null> = ref(null)
const installedProjectIds: Ref<string[] | null> = ref(null)
const instanceHideInstalled = ref(false)
const newlyInstalled = ref<string[]>([])
const hiddenInstanceProjectIds = ref<Set<string>>(new Set())
const hiddenInstanceProjectIdsInitialized = ref(false)
const isServerInstance = ref(false)
if (isFromWorlds.value && route.params.projectType !== 'server') {
@@ -142,6 +144,25 @@ const allInstalledIds = computed(
() => new Set([...newlyInstalled.value, ...(installedProjectIds.value ?? [])]),
)
function syncHiddenInstanceProjectIds() {
hiddenInstanceProjectIds.value = new Set([
...(installedProjectIds.value ?? []),
...newlyInstalled.value,
])
hiddenInstanceProjectIdsInitialized.value = true
}
watch(
installedProjectIds,
(ids) => {
if (!ids) return
if (!hiddenInstanceProjectIdsInitialized.value) {
syncHiddenInstanceProjectIds()
}
},
{ immediate: true },
)
watchServerContextChanges()
await initInstanceContext()
@@ -222,14 +243,10 @@ const instanceFilters = computed(() => {
filters.push({ type: 'environment', option: 'client' })
}
if (
instanceHideInstalled.value &&
(installedProjectIds.value || newlyInstalled.value.length > 0)
) {
const allInstalled = [...(installedProjectIds.value ?? []), ...newlyInstalled.value]
allInstalled
.map((x) => ({ type: 'project_id', option: `project_id:${x}`, negative: true }))
.forEach((x) => filters.push(x))
if (instanceHideInstalled.value && hiddenInstanceProjectIds.value.size > 0) {
for (const id of hiddenInstanceProjectIds.value) {
filters.push({ type: 'project_id', option: `project_id:${id}`, negative: true })
}
}
}
@@ -240,6 +257,23 @@ const serverHideInstalled = ref(false)
if (route.query.shi) {
serverHideInstalled.value = route.query.shi === 'true'
}
const hiddenServerContentProjectIds = ref<Set<string>>(new Set())
const hiddenServerContentProjectIdsInitialized = ref(false)
function syncHiddenServerContentProjectIds() {
hiddenServerContentProjectIds.value = new Set(serverContentProjectIds.value)
hiddenServerContentProjectIdsInitialized.value = true
}
watch(
serverContentProjectIds,
() => {
if (!hiddenServerContentProjectIdsInitialized.value) {
syncHiddenServerContentProjectIds()
}
},
{ immediate: true },
)
const serverContextFilters = computed(() => {
const filters: { type: string; option: string; negative?: boolean }[] = []
@@ -266,8 +300,8 @@ const serverContextFilters = computed(() => {
)
}
if (serverHideInstalled.value && serverContentProjectIds.value.size > 0) {
for (const id of serverContentProjectIds.value) {
if (serverHideInstalled.value && hiddenServerContentProjectIds.value.size > 0) {
for (const id of hiddenServerContentProjectIds.value) {
filters.push({ type: 'project_id', option: `project_id:${id}`, negative: true })
}
}
@@ -280,6 +314,9 @@ const combinedProvidedFilters = computed(() =>
)
const serverPings = shallowRef<Record<string, number | undefined>>({})
const serverPingCache = new Map<string, number | undefined>()
const pendingServerPings = new Map<string, Promise<number | undefined>>()
let serverPingCacheActive = true
const runningServerProjects = ref<Record<string, string>>({})
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
@@ -342,16 +379,44 @@ async function handleAddServerToInstance(project: Labrinth.Search.v3.ResultSearc
async function pingServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) {
debugLog('pingServerHits', { hitCount: hits.length })
const pingsToFetch = hits.filter((hit) => hit.minecraft_java_server?.address)
const pingsToFetch = hits.flatMap((hit) => {
const address = hit.minecraft_java_server?.address
if (!address) return []
return [{ hit, address }]
})
const nextPings = { ...serverPings.value }
for (const { hit, address } of pingsToFetch) {
if (serverPingCache.has(address)) {
nextPings[hit.project_id] = serverPingCache.get(address)
}
}
serverPings.value = nextPings
await Promise.all(
pingsToFetch.map(async (hit) => {
const address = hit.minecraft_java_server!.address!
try {
const latency = await getServerLatency(address)
serverPings.value = { ...serverPings.value, [hit.project_id]: latency }
} catch (err) {
console.error(`Failed to ping server ${address}:`, err)
pingsToFetch.map(async ({ hit, address }) => {
if (serverPingCache.has(address)) return
let pending = pendingServerPings.get(address)
if (!pending) {
pending = getServerLatency(address)
.then((latency) => {
if (serverPingCacheActive) serverPingCache.set(address, latency)
return latency
})
.catch((err) => {
console.error(`Failed to ping server ${address}:`, err)
if (serverPingCacheActive) serverPingCache.set(address, undefined)
return undefined
})
.finally(() => {
pendingServerPings.delete(address)
})
pendingServerPings.set(address, pending)
}
const latency = await pending
if (!serverPingCacheActive) return
serverPings.value = { ...serverPings.value, [hit.project_id]: latency }
}),
)
}
@@ -372,7 +437,10 @@ const unlistenProcesses = await process_listener(
)
onUnmounted(() => {
serverPingCacheActive = false
unlistenProcesses()
serverPingCache.clear()
pendingServerPings.clear()
})
const offline = ref(!navigator.onLine)
@@ -432,11 +500,11 @@ const messages = defineMessages({
},
hideAddedServers: {
id: 'app.browse.hide-added-servers',
defaultMessage: 'Hide added servers',
defaultMessage: 'Hide already added servers',
},
hideInstalledContent: {
id: 'app.browse.hide-installed-content',
defaultMessage: 'Hide installed content',
defaultMessage: 'Hide already installed content',
},
installContentToInstance: {
id: 'app.browse.install-content-to-instance',
@@ -663,6 +731,16 @@ const installContext = computed(() => {
const installingProjectIds = ref<Set<string>>(new Set())
function setProjectInstalling(projectId: string, installing: boolean) {
const next = new Set(installingProjectIds.value)
if (installing) {
next.add(projectId)
} else {
next.delete(projectId)
}
installingProjectIds.value = next
}
function getCardActions(
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
currentProjectType: string,
@@ -757,11 +835,12 @@ function getCardActions(
: messages.installToServer,
),
icon: isInstalled ? CheckIcon : PlusIcon,
iconClass: isInstalling ? 'animate-spin' : undefined,
disabled: isInstalled || isInstalling,
color: 'brand',
type: 'outlined',
onClick: async () => {
installingProjectIds.value.add(projectResult.project_id)
setProjectInstalling(projectResult.project_id, true)
try {
const didInstall = await installProjectToServer(projectResult)
if (didInstall !== false) {
@@ -770,7 +849,7 @@ function getCardActions(
} catch (err) {
handleError(err as Error)
} finally {
installingProjectIds.value.delete(projectResult.project_id)
setProjectInstalling(projectResult.project_id, false)
}
},
},
@@ -791,18 +870,19 @@ function getCardActions(
? 'Install'
: 'Add to an instance',
icon: isInstalling ? SpinnerIcon : isInstalled ? CheckIcon : PlusIcon,
iconClass: isInstalling ? 'animate-spin' : undefined,
disabled: isInstalled || isInstalling,
color: 'brand',
type: 'outlined',
onClick: async () => {
installingProjectIds.value.add(projectResult.project_id)
setProjectInstalling(projectResult.project_id, true)
await installVersion(
projectResult.project_id,
null,
instance.value ? instance.value.path : null,
'SearchCard',
(versionId) => {
installingProjectIds.value.delete(projectResult.project_id)
setProjectInstalling(projectResult.project_id, false)
if (versionId) {
onSearchResultInstalled(projectResult.project_id)
}
@@ -815,7 +895,7 @@ function getCardActions(
preferredGameVersion: instance.value?.game_version ?? undefined,
},
).catch((err) => {
installingProjectIds.value.delete(projectResult.project_id)
setProjectInstalling(projectResult.project_id, false)
handleError(err)
})
},
@@ -858,7 +938,6 @@ async function search(requestParams: string) {
if (isServer) {
const hits = rawResults.result.hits ?? []
lastServerHits.value = hits
serverPings.value = {}
pingServerHits(hits)
checkServerRunningStates(hits)
return {
@@ -928,6 +1007,23 @@ const searchState = useBrowseSearch({
}),
})
watch(
[
() => searchState.query.value,
() => searchState.currentFilters.value,
() => searchState.serverCurrentFilters.value,
() => projectType.value,
],
() => {
if (isServerContext.value) {
syncHiddenServerContentProjectIds()
} else if (instance.value) {
syncHiddenInstanceProjectIds()
}
},
{ deep: true },
)
if (instance.value?.game_version) {
const gv = instance.value.game_version
const alreadyHasGv = searchState.serverCurrentFilters.value.some(
@@ -959,8 +1055,13 @@ provideBrowseManager({
hideInstalled: computed({
get: () => (isServerContext.value ? serverHideInstalled.value : instanceHideInstalled.value),
set: (val: boolean) => {
if (isServerContext.value) serverHideInstalled.value = val
else instanceHideInstalled.value = val
if (isServerContext.value) {
serverHideInstalled.value = val
if (val) syncHiddenServerContentProjectIds()
} else {
instanceHideInstalled.value = val
if (val) syncHiddenInstanceProjectIds()
}
},
}),
showHideInstalled: computed(

View File

@@ -6,7 +6,6 @@
:resolve-viewer="resolveViewer"
:show-copy-id-action="themeStore.devMode"
:auth-user="authUser"
:fetch-intercom-token="fetchIntercomToken"
:navigate-to-billing="() => openUrl('https://modrinth.com/settings/billing')"
:navigate-to-servers="() => router.push('/hosting/manage')"
:browse-modpacks="
@@ -47,12 +46,10 @@
import type { Archon, Labrinth } from '@modrinth/api-client'
import { injectAuth, injectModrinthClient, ServersManageRootLayout } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { config } from '@/config'
import { get_user } from '@/helpers/cache'
import { get as getCreds } from '@/helpers/mr_auth'
import { useBreadcrumbs } from '@/store/breadcrumbs'
@@ -123,26 +120,6 @@ const authUser = computed(() => {
}
})
async function fetchIntercomToken(): Promise<{ token: string }> {
const credentials = await getCreds()
if (!credentials?.session) {
throw new Error('Not authenticated')
}
const response = await tauriFetch(
`${config.siteUrl}/api/intercom/messenger-jwt?server_id=${encodeURIComponent(serverId.value)}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${credentials.session}`,
},
},
)
if (!response.ok) {
throw new Error(`Failed to fetch Intercom token: ${response.status}`)
}
return (await response.json()) as { token: string }
}
async function resolveViewer(): Promise<{ userId: string | null; userRole: string | null }> {
const credentials = await getCreds().catch(() => null)
if (!credentials?.user_id) {

View File

@@ -240,7 +240,7 @@ export default new createRouter({
component: Instance.Logs,
meta: {
useRootContext: true,
renderMode: 'fixed',
// renderMode: 'fixed',
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Logs' }],
},
},