fix: moderation/projects endpoint slow (#6639)

* fix: moderation/projects endpoint slow

* fix: re-add tech rev toggle off by default

* fix: i18n

* prepare

---------

Co-authored-by: aecsocket <43144841+aecsocket@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-07-06 19:16:52 +00:00
committed by GitHub
co-authored by aecsocket
parent c831e38e32
commit 8d20fd82db
17 changed files with 1477 additions and 420 deletions
@@ -2,12 +2,7 @@
<div class="shadow-card rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<NuxtLink
:to="`/project/${queueEntry.project.slug}`"
target="_blank"
tabindex="-1"
class="flex"
>
<NuxtLink :to="`/project/${projectRouteParam}`" target="_blank" tabindex="-1" class="flex">
<Avatar
:src="queueEntry.project.icon_url"
size="4rem"
@@ -17,7 +12,7 @@
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-2">
<NuxtLink
:to="`/project/${queueEntry.project.slug}`"
:to="`/project/${projectRouteParam}`"
target="_blank"
class="text-lg font-semibold text-contrast hover:underline"
>
@@ -177,7 +172,7 @@ function getDaysQueued(date: Date): number {
const queuedDate = computed(() => {
return dayjs(
props.queueEntry.project.queued ||
props.queueEntry.project.created ||
props.queueEntry.project.published ||
props.queueEntry.project.updated,
)
})
@@ -186,10 +181,14 @@ const daysInQueue = computed(() => {
return getDaysQueued(queuedDate.value.toDate())
})
const projectRouteParam = computed(
() => props.queueEntry.project.slug || props.queueEntry.project.id,
)
const formattedDate = computed(() => {
const date =
props.queueEntry.project.queued ||
props.queueEntry.project.created ||
props.queueEntry.project.published ||
props.queueEntry.project.updated
if (!date) return 'Unknown'
@@ -202,7 +201,7 @@ const formattedDate = computed(() => {
function copyLink() {
const base = window.location.origin
const projectUrl = `${base}/project/${props.queueEntry.project.slug}`
const projectUrl = `${base}/project/${projectRouteParam.value}`
navigator.clipboard.writeText(projectUrl).then(() => {
addNotification({
type: 'success',
@@ -1949,7 +1949,10 @@ function generateModpackMessage(allFiles: {
const hasNextProject = ref(false)
async function refreshModerationCaches(threadId?: string) {
const refreshes: Promise<unknown>[] = [invalidate(), refreshNuxtData('moderation-projects')]
const refreshes: Promise<unknown>[] = [
invalidate(),
queryClient.invalidateQueries({ queryKey: ['moderation-projects'] }),
]
if (threadId) {
refreshes.push(queryClient.invalidateQueries({ queryKey: ['thread', threadId] }))
+3 -6
View File
@@ -1,3 +1,4 @@
import type { Labrinth } from '@modrinth/api-client'
import type { ExtendedReport, OwnershipTarget } from '@modrinth/moderation'
import type {
Organization,
@@ -197,14 +198,10 @@ export interface ModerationOwnershipOrganization {
export type ModerationOwnership = ModerationOwnershipUser | ModerationOwnershipOrganization
export interface ProjectWithOwnership {
ownership: ModerationOwnership
external_dependencies_count: number
[key: string]: any
}
export type ProjectWithOwnership = Labrinth.Moderation.Internal.QueueProject
export interface ModerationProject {
project: any
project: Omit<ProjectWithOwnership, 'ownership' | 'external_dependencies_count'>
ownership: ModerationOwnership | null
external_dependencies_count: number
}
@@ -2672,6 +2672,9 @@
"layout.nav.upgrade-to-modrinth-plus": {
"message": "Upgrade to Modrinth+"
},
"moderation.exclude-technical-review": {
"message": "Exclude TR"
},
"moderation.moderate": {
"message": "Moderate"
},
+167 -188
View File
@@ -18,6 +18,7 @@
<Combobox
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
trigger-class="!h-10"
:options="filterTypes"
:placeholder="formatMessage(commonMessages.filterByLabel)"
@select="goToPage(1)"
@@ -26,7 +27,7 @@
<span class="flex flex-row gap-2 align-middle font-semibold">
<ListFilterIcon class="size-5 flex-shrink-0 text-secondary" />
<span class="truncate text-contrast"
>{{ currentFilterType }} ({{ filteredProjects.length }})</span
>{{ currentFilterType }} ({{ totalProjects }})</span
>
</span>
</template>
@@ -35,6 +36,7 @@
<Combobox
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[240px] sm:flex-grow-0"
trigger-class="!h-10"
:options="sortTypes"
:placeholder="formatMessage(commonMessages.sortByLabel)"
@select="goToPage(1)"
@@ -54,6 +56,7 @@
<Combobox
v-model="itemsPerPage"
class="!w-full flex-grow sm:!w-[160px] sm:flex-grow-0 lg:!w-[140px]"
trigger-class="!h-10"
:options="itemsPerPageOptions"
placeholder="Items per page"
@select="goToPage(1)"
@@ -69,7 +72,7 @@
<ButtonStyled color="orange">
<button
class="flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto"
:disabled="paginatedProjects?.length === 0"
:disabled="pending || paginatedProjects?.length === 0"
@click="moderateAllInFilter()"
>
<ScaleIcon class="flex-shrink-0" />
@@ -80,17 +83,27 @@
</div>
</div>
<div v-if="totalPages > 1" class="flex items-center justify-between">
<div>
Showing {{ itemsPerPage * (currentPage - 1) + 1 }}{{
itemsPerPage * (currentPage - 1) + Math.min(itemsPerPage, paginatedProjects.length)
}}
of {{ filteredProjects.length }}
{{
currentFilterType === DEFAULT_FILTER_TYPE ? 'projects' : currentFilterType.toLowerCase()
}}
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap items-center gap-3">
<div v-if="totalProjects > 0">
Showing {{ pageStart }}{{ pageEnd }} of {{ totalProjects }}
{{
currentFilterType === DEFAULT_FILTER_TYPE ? 'projects' : currentFilterType.toLowerCase()
}}
</div>
<div class="flex items-center gap-2 text-sm font-semibold text-secondary">
<Toggle id="moderation-exclude-technical-review" v-model="excludeTechnicalReview" small />
<label class="cursor-pointer" for="moderation-exclude-technical-review">
{{ formatMessage(messages.excludeTechnicalReview) }}
</label>
</div>
</div>
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
<Pagination
v-if="totalPages > 1"
:page="currentPage"
:count="totalPages"
@switch-page="goToPage"
/>
<ConfettiExplosion v-if="visible" />
</div>
@@ -124,6 +137,7 @@
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ListFilterIcon, ScaleIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
import {
ButtonStyled,
@@ -132,20 +146,18 @@ import {
commonMessages,
defineMessages,
EmptyState,
injectModrinthClient,
injectNotificationManager,
Pagination,
StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
import Fuse from 'fuse.js'
import { useQuery } from '@tanstack/vue-query'
import ConfettiExplosion from 'vue-confetti-explosion'
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
import {
type ModerationProject,
type ProjectWithOwnership,
toModerationProjects,
} from '~/helpers/moderation.ts'
import { type ModerationProject, toModerationProjects } from '~/helpers/moderation.ts'
import { useModerationQueue } from '~/services/moderation-queue.ts'
useHead({ title: 'Projects queue - Modrinth' })
@@ -155,6 +167,7 @@ const { addNotification } = injectNotificationManager()
const moderationQueue = useModerationQueue()
const route = useRoute()
const router = useRouter()
const client = injectModrinthClient()
const visible = ref(false)
if (import.meta.client && history && history.state && history.state.confetti) {
@@ -173,37 +186,14 @@ const messages = defineMessages({
id: 'moderation.moderate',
defaultMessage: 'Moderate',
},
})
const { data: allProjects, pending } = await useLazyAsyncData('moderation-projects', async () => {
const startTime = performance.now()
let currentOffset = 0
const PROJECT_ENDPOINT_COUNT = 350
const allProjects: ModerationProject[] = []
let projects: ProjectWithOwnership[] = []
do {
projects = (await useBaseFetch(
`moderation/projects?count=${PROJECT_ENDPOINT_COUNT}&offset=${currentOffset}`,
{ internal: true },
)) as ProjectWithOwnership[]
if (projects.length === 0) break
allProjects.push(...toModerationProjects(projects))
currentOffset += projects.length
} while (projects.length === PROJECT_ENDPOINT_COUNT)
const duration = performance.now() - startTime
console.debug(
`Projects fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
)
return allProjects
excludeTechnicalReview: {
id: 'moderation.exclude-technical-review',
defaultMessage: 'Exclude TR',
},
})
const query = ref(route.query.q?.toString() || '')
const excludeTechnicalReview = ref(false)
watch(
query,
@@ -379,116 +369,106 @@ const itemsPerPage = computed({
})
const currentPage = ref(1)
const totalPages = computed(() =>
Math.ceil((filteredProjects.value?.length || 0) / itemsPerPage.value),
function toApiProjectType(label: string): string | undefined {
switch (label) {
case 'Modpacks':
return 'modpack'
case 'Mods':
return 'mod'
case 'Resource Packs':
return 'resourcepack'
case 'Data Packs':
return 'datapack'
case 'Plugins':
return 'plugin'
case 'Shaders':
return 'shader'
case 'Servers':
return 'minecraft_java_server'
case 'Fucked up':
return 'none'
default:
return undefined
}
}
function toApiSort(label: string): Labrinth.Moderation.Internal.ProjectsSort {
switch (label) {
case 'Newest':
return 'newest'
case 'Most external deps':
return 'most_external_deps'
case 'Least external deps':
return 'least_external_deps'
default:
return 'oldest'
}
}
const moderationProjectsRequest = computed<Labrinth.Moderation.Internal.ProjectsRequest>(() => ({
count: itemsPerPage.value,
offset: (currentPage.value - 1) * itemsPerPage.value,
exclude_technical_review: excludeTechnicalReview.value,
query: query.value || undefined,
project_type: toApiProjectType(currentFilterType.value),
sort: toApiSort(currentSortType.value),
}))
const moderationProjectsQueryKey = computed(
() => ['moderation-projects', moderationProjectsRequest.value] as const,
)
const fuse = computed(() => {
if (!allProjects.value || allProjects.value.length === 0) return null
return new Fuse(allProjects.value, {
keys: [
{
name: 'project.title',
weight: 3,
},
{
name: 'project.slug',
weight: 2,
},
{
name: 'project.description',
weight: 2,
},
{
name: 'project.project_type',
weight: 1,
},
'ownership.name',
],
includeScore: true,
threshold: 0.4,
})
const {
data: moderationProjectsResponse,
isPending: moderationProjectsPending,
isPlaceholderData: moderationProjectsPlaceholder,
} = useQuery({
queryKey: moderationProjectsQueryKey,
queryFn: ({ queryKey }) => client.labrinth.moderation_internal.getProjects(queryKey[1]),
placeholderData: (previousData) => previousData,
})
const searchResults = computed(() => {
if (!query.value || !fuse.value) return null
return fuse.value.search(query.value).map((result) => result.item)
})
const baseFiltered = computed(() => {
if (!allProjects.value) return []
return query.value && searchResults.value ? searchResults.value : [...allProjects.value]
})
const typeFiltered = computed(() => {
if (currentFilterType.value === 'All projects') {
return baseFiltered.value
} else if (currentFilterType.value === 'Fucked up') {
return baseFiltered.value.filter((queueItem) => queueItem.project.project_types.length === 0)
const pending = computed(
() => moderationProjectsPending.value || moderationProjectsPlaceholder.value,
)
const totalProjects = computed(() => moderationProjectsResponse.value?.total ?? 0)
const totalPages = computed(() => Math.ceil(totalProjects.value / itemsPerPage.value))
const filteredProjects = computed(() =>
toModerationProjects(moderationProjectsResponse.value?.projects ?? []),
)
const paginatedProjects = computed(() => filteredProjects.value)
const pageStart = computed(() =>
totalProjects.value === 0 ? 0 : (currentPage.value - 1) * itemsPerPage.value + 1,
)
const pageEnd = computed(() =>
Math.min(
(currentPage.value - 1) * itemsPerPage.value + paginatedProjects.value.length,
totalProjects.value,
),
)
const projectsById = computed(() => {
const projects = new Map<string, ModerationProject>()
for (const project of filteredProjects.value) {
projects.set(project.project.id, project)
}
const filterMap: Record<string, string> = {
Modpacks: 'modpack',
Mods: 'mod',
'Resource Packs': 'resourcepack',
'Data Packs': 'datapack',
Plugins: 'plugin',
Shaders: 'shader',
Servers: 'minecraft_java_server',
}
const projectType = filterMap[currentFilterType.value]
if (!projectType) return baseFiltered.value
return baseFiltered.value.filter(
(queueItem) =>
(queueItem.project.project_types.length > 0 &&
queueItem.project.project_types[0] === projectType) ||
(projectType === 'minecraft_java_server' &&
queueItem.project.project_types.includes('minecraft_java_server')),
)
return projects
})
const filteredProjects = computed(() => {
const filtered = [...typeFiltered.value]
if (currentSortType.value === 'Most external deps') {
filtered.sort((a, b) => {
const depsDiff = b.external_dependencies_count - a.external_dependencies_count
if (depsDiff !== 0) return depsDiff
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
return dateA - dateB
})
} else if (currentSortType.value === 'Least external deps') {
filtered.sort((a, b) => {
const depsDiff = a.external_dependencies_count - b.external_dependencies_count
if (depsDiff !== 0) return depsDiff
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
return dateA - dateB
})
} else if (currentSortType.value === 'Oldest') {
filtered.sort((a, b) => {
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
return dateA - dateB
})
} else {
filtered.sort((a, b) => {
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
return dateB - dateA
})
watch(totalPages, (pages) => {
if (pages === 0 && currentPage.value !== 1) {
currentPage.value = 1
return
}
return filtered
if (pages > 0 && currentPage.value > pages) {
currentPage.value = pages
}
})
const paginatedProjects = computed(() => {
if (!filteredProjects.value) return []
const start = (currentPage.value - 1) * itemsPerPage.value
const end = start + itemsPerPage.value
return filteredProjects.value.slice(start, end)
watch(excludeTechnicalReview, () => {
goToPage(1)
})
const emptyStateHeading = computed(() => {
@@ -525,21 +505,16 @@ function notifySkippedProjects(skippedCount: number) {
})
}
async function findFirstEligibleProject(): Promise<ModerationProject | null> {
async function findFirstEligibleProject(): Promise<string | null> {
let skippedCount = 0
while (moderationQueue.hasItems) {
const currentId = moderationQueue.getCurrentProjectId()
if (!currentId) return null
const project = filteredProjects.value.find((p) => p.project.id === currentId)
if (!project) {
await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++
continue
}
const project = projectsById.value.get(currentId)
if (project.project.status !== 'processing') {
if (project && project.project.status !== 'processing') {
await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++
continue
@@ -550,13 +525,13 @@ async function findFirstEligibleProject(): Promise<ModerationProject | null> {
if (!lockStatus.locked || lockStatus.expired || lockStatus.is_own_lock) {
notifySkippedProjects(skippedCount)
return project
return currentId
}
await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++
} catch {
return project
return currentId
}
}
@@ -565,17 +540,42 @@ async function findFirstEligibleProject(): Promise<ModerationProject | null> {
return null
}
function getProjectRouteParam(projectId: string): string {
return projectsById.value.get(projectId)?.project.slug || projectId
}
async function navigateToModerationProject(projectId: string) {
await navigateTo({
name: 'type-project',
params: {
type: 'project',
project: getProjectRouteParam(projectId),
},
state: {
showChecklist: true,
},
})
}
async function getFilteredProjectIds(): Promise<string[]> {
const response = await client.labrinth.moderation_internal.getProjectIds({
exclude_technical_review: excludeTechnicalReview.value,
query: query.value || undefined,
project_type: toApiProjectType(currentFilterType.value),
sort: toApiSort(currentSortType.value),
})
return response.ids
}
async function moderateAllInFilter() {
// Start from the current page - get projects from current page onwards
const startIndex = (currentPage.value - 1) * itemsPerPage.value
const projectsFromCurrentPage = filteredProjects.value.slice(startIndex)
const projectIds = projectsFromCurrentPage.map((queueItem) => queueItem.project.id)
const projectIds = (await getFilteredProjectIds()).slice(startIndex)
await moderationQueue.setQueue(projectIds)
// Find first unlocked project
const targetProject = await findFirstEligibleProject()
const targetProjectId = await findFirstEligibleProject()
if (!targetProject) {
if (!targetProjectId) {
addNotification({
title: 'No projects available',
text: 'All projects in queue are already moderated or locked by others.',
@@ -584,34 +584,22 @@ async function moderateAllInFilter() {
return
}
navigateTo({
name: 'type-project',
params: {
type: 'project',
project: targetProject.project.slug,
},
state: {
showChecklist: true,
},
})
await navigateToModerationProject(targetProjectId)
}
async function startFromProject(projectId: string) {
// Find the index of the clicked project in the filtered list
const projectIndex = filteredProjects.value.findIndex((p) => p.project.id === projectId)
const allFilteredProjectIds = await getFilteredProjectIds()
const projectIndex = allFilteredProjectIds.indexOf(projectId)
if (projectIndex === -1) {
// Project not found in filtered list, just moderate it alone
await moderationQueue.setSingleProject(projectId)
} else {
// Start queue from this project onwards
const projectsFromHere = filteredProjects.value.slice(projectIndex)
const projectIds = projectsFromHere.map((queueItem) => queueItem.project.id)
const projectIds = allFilteredProjectIds.slice(projectIndex)
await moderationQueue.setQueue(projectIds)
}
const targetProject = await findFirstEligibleProject()
const targetProjectId = await findFirstEligibleProject()
if (!targetProject) {
if (!targetProjectId) {
addNotification({
title: 'No projects available',
text: 'All projects in queue are already moderated or locked by others.',
@@ -620,15 +608,6 @@ async function startFromProject(projectId: string) {
return
}
navigateTo({
name: 'type-project',
params: {
type: 'project',
project: targetProject.project.slug,
},
state: {
showChecklist: true,
},
})
await navigateToModerationProject(targetProjectId)
}
</script>