feat(moderation): modpack scanning improvements (#6843)

* feat(moderation): make modpack scanning improvements

* oopsie

* feat(moderation): batch size for modpack scan

* feat: i18n for modpack scan modal

* chore: remove debug line

* run intl:extract and prepr

---------

Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
jade
2026-07-26 00:14:09 +00:00
committed by GitHub
co-authored by coolbot100s Prospector
parent 2cf71a53f2
commit b4380aee87
13 changed files with 229 additions and 116 deletions
@@ -1,8 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client' import type { Labrinth } from '@modrinth/api-client'
import { FolderSearchIcon, StarIcon, TrashIcon } from '@modrinth/assets' import {
FolderSearchIcon,
RotateCounterClockwiseIcon,
SpinnerIcon,
StarIcon,
TrashIcon,
} from '@modrinth/assets'
import { import {
ButtonStyled, ButtonStyled,
Combobox,
type ComboboxOption,
ConfirmModal, ConfirmModal,
defineMessages, defineMessages,
injectModrinthClient, injectModrinthClient,
@@ -12,6 +20,7 @@ import {
type TableColumn, type TableColumn,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import { computed, ref, useTemplateRef } from 'vue' import { computed, ref, useTemplateRef } from 'vue'
@@ -52,6 +61,10 @@ const messages = defineMessages({
id: 'modpack-scan-modal.scanning', id: 'modpack-scan-modal.scanning',
defaultMessage: 'Scanning...', defaultMessage: 'Scanning...',
}, },
success: {
id: 'modpack-scan-modal.success',
defaultMessage: 'Success',
},
failed: { failed: {
id: 'modpack-scan-modal.failed', id: 'modpack-scan-modal.failed',
defaultMessage: 'Failed', defaultMessage: 'Failed',
@@ -66,11 +79,40 @@ const messages = defineMessages({
}, },
scanError: { scanError: {
id: 'modpack-scan-modal.scan-error', id: 'modpack-scan-modal.scan-error',
defaultMessage: 'Some files failed to scan: {error}', defaultMessage: 'Some files failed to scan: \n\n{error}',
}, },
clearAllGroups: { batchPlaceholder: {
id: 'modpack-scan-modal.clear-all-groups', id: 'modpack-scan-modal.batch.placeholder',
defaultMessage: 'Clear All Groups', defaultMessage: 'Batch amount',
},
batchLabel: {
id: 'modpack-scan-modal.batch.label',
defaultMessage: '{amount} per batch',
},
deleteAllGroups: {
id: 'project.settings.permissions.delete-all-groups',
defaultMessage: 'Delete all groups',
},
deleteAllGroupsConfirmationTitle: {
id: 'project.settings.permissions.delete-all-groups-confirmation.title',
defaultMessage: 'Delete all attribution groups?',
},
deleteAllGroupsConfirmationDescription: {
id: 'modpack-scan-modal.delete-all-groups-confirmation.description',
defaultMessage:
'This will permanently delete all attribution groups for this project and all files inside them. This action cannot be undone.',
},
deleteAllGroupsConfirmationProceed: {
id: 'modpack-scan-modal.delete-all-groups.proceed',
defaultMessage: 'Clear',
},
deleteAllGroupsSuccess: {
id: 'modpack-scan-modal.delete-all-groups.success',
defaultMessage: 'All groups cleared successfully.',
},
deleteAllGroupsError: {
id: 'modpack-scan-modal.delete-all-groups.error',
defaultMessage: 'Failed to clear all groups: {error}',
}, },
}) })
@@ -98,23 +140,64 @@ const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')
const clearModalRef = useTemplateRef<InstanceType<typeof ConfirmModal>>('clearModalRef') const clearModalRef = useTemplateRef<InstanceType<typeof ConfirmModal>>('clearModalRef')
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const rows = ref<ScanRow[]>([]) const rows = ref<Record<string, ScanRow>>({})
const isLoadingVersions = ref(false) const isLoadingVersions = ref(false)
const isScanning = ref(false) const isScanning = ref(false)
const isClearing = ref(false) const isClearing = ref(false)
const versionLoadError = ref<string | null>(null) const versionLoadError = ref<string | null>(null)
const scanError = ref<string | null>(null)
const requestId = ref(0) const requestId = ref(0)
const scanRequestId = ref(0) const scanRequestId = ref(0)
const DEFAULT_BATCH_AMOUNT = 10
const batchAmountOptions: ComboboxOption<number>[] = [
{ value: 1, label: '1' },
{ value: 5, label: '5' },
{ value: 10, label: '10' },
{ value: 20, label: '20' },
{ value: 50, label: '50' },
]
const batchAmountValues = batchAmountOptions.map((opt) => opt.value)
const batchAmountCookie = useCookie<number>('moderation-modpack-scan-batch', {
default: () => DEFAULT_BATCH_AMOUNT,
maxAge: 60 * 60 * 24 * 365,
sameSite: 'lax',
path: '/',
})
const batchAmount = computed({
get() {
const value = Number(batchAmountCookie.value)
return batchAmountValues.includes(value) ? value : DEFAULT_BATCH_AMOUNT
},
set(value: number) {
batchAmountCookie.value = value
},
})
const columns = computed<TableColumn<ScanTableColumn>[]>(() => [ const columns = computed<TableColumn<ScanTableColumn>[]>(() => [
{ key: 'filename', label: formatMessage(messages.packFileName), width: '60%' }, { key: 'filename', label: formatMessage(messages.packFileName), width: '60%' },
{ key: 'newFiles', label: formatMessage(messages.newFiles), align: 'center', width: '20%' }, { key: 'newFiles', label: formatMessage(messages.newFiles), align: 'center', width: '20%' },
{ key: 'newGroups', label: formatMessage(messages.newGroups), align: 'center', width: '20%' }, { key: 'newGroups', label: formatMessage(messages.newGroups), align: 'center', width: '20%' },
]) ])
const scannedCount = computed(() => rows.value.filter((row) => row.scan || row.error).length) const scannedCount = computed(
() => Object.entries(rows.value).filter(([_, row]) => row.scan || row.error).length,
)
const isBusy = computed(() => isLoadingVersions.value || isScanning.value || isClearing.value) const isBusy = computed(() => isLoadingVersions.value || isScanning.value || isClearing.value)
const titleButtonsDisabled = computed(() => isBusy.value || Object.keys(rows.value).length === 0)
const rescanButtonsDisabled = computed(() => isLoadingVersions.value || isClearing.value)
const rowErrors = computed(() =>
Object.entries(rows.value)
.filter(([_, row]) => row.error)
.map(([_, row]) => row),
)
const rowScanError = computed(() => {
if (rowErrors.value.length === 0) return undefined
return formatMessage(messages.scanError, {
error: rowErrors.value.map((r) => `\n - ${r.filename}`).join(''),
})
})
function getErrorMessage(error: unknown) { function getErrorMessage(error: unknown) {
if (error instanceof Error) { if (error instanceof Error) {
@@ -131,12 +214,27 @@ function getErrorMessage(error: unknown) {
return String(error) return String(error)
} }
async function runWithConcurrency<T>(
items: T[],
limit: number,
task: (item: T) => Promise<void>,
): Promise<void> {
const queue = [...items]
const workers = Array.from({ length: limit }, async () => {
while (queue.length) {
const item = queue.shift()
if (item === undefined) return
await task(item)
}
})
await Promise.all(workers)
}
async function fetchAllVersions() { async function fetchAllVersions() {
const currentRequestId = ++requestId.value const currentRequestId = ++requestId.value
isLoadingVersions.value = true isLoadingVersions.value = true
versionLoadError.value = null versionLoadError.value = null
scanError.value = null rows.value = {}
rows.value = []
try { try {
const versions = await client.labrinth.versions_v2.getProjectVersions(props.project_id) const versions = await client.labrinth.versions_v2.getProjectVersions(props.project_id)
@@ -144,17 +242,20 @@ async function fetchAllVersions() {
return return
} }
rows.value = versions const filteredVersions = versions
.flatMap((version) => version.files) .flatMap((version) => version.files)
.filter((file): file is Labrinth.Versions.v2.VersionFile & { id: string } => Boolean(file.id)) .filter((file): file is Labrinth.Versions.v2.VersionFile & { id: string } => Boolean(file.id))
.map((file) => ({
id: file.id, for (const version of filteredVersions) {
filename: file.filename, rows.value[version.id] = {
primary: file.primary, id: version.id,
filename: version.filename,
primary: version.primary,
isScanning: false, isScanning: false,
newFiles: undefined, newFiles: undefined,
newGroups: undefined, newGroups: undefined,
})) }
}
} catch (error) { } catch (error) {
if (currentRequestId === requestId.value) { if (currentRequestId === requestId.value) {
versionLoadError.value = formatMessage(messages.loadVersionsError, { versionLoadError.value = formatMessage(messages.loadVersionsError, {
@@ -168,54 +269,45 @@ async function fetchAllVersions() {
} }
} }
async function fetchScan(id: string) {
rows.value[id].isScanning = true
try {
const scan = await client.labrinth.attribution_internal.scanFile(id)
rows.value[id].scan = scan
rows.value[id].newFiles = scan.new_attribution_files
rows.value[id].newGroups = scan.new_attribution_groups
rows.value[id].error = undefined
} catch (error) {
rows.value[id].error = getErrorMessage(error)
} finally {
rows.value[id].isScanning = false
}
}
async function fetchAllScans() { async function fetchAllScans() {
if (isBusy.value) { if (isBusy.value) return
return
}
const currentScanRequestId = ++scanRequestId.value
isScanning.value = true isScanning.value = true
scanError.value = null
rows.value = rows.value.map((row) => ({ Object.entries(rows.value).map(([id, row]) => {
...row, rows.value[id] = {
scan: undefined, ...row,
isScanning: false, scan: undefined,
error: undefined, isScanning: false,
newFiles: undefined, error: undefined,
newGroups: undefined, newFiles: undefined,
})) newGroups: undefined,
}
})
try { try {
for (const row of rows.value) { await runWithConcurrency(Object.keys(rows.value), batchAmount.value, async (id: string) => {
if (currentScanRequestId !== scanRequestId.value) { await fetchScan(id)
return })
}
row.isScanning = true
try {
const scan = await client.labrinth.attribution_internal.scanFile(row.id)
if (currentScanRequestId !== scanRequestId.value) {
return
}
row.scan = scan
row.newFiles = scan.new_attribution_files
row.newGroups = scan.new_attribution_groups
} catch (error) {
if (currentScanRequestId !== scanRequestId.value) {
return
}
row.error = getErrorMessage(error)
scanError.value = formatMessage(messages.scanError, { error: row.error })
} finally {
row.isScanning = false
}
}
} finally { } finally {
if (currentScanRequestId === scanRequestId.value) { isScanning.value = false
isScanning.value = false
}
} }
} }
@@ -242,8 +334,8 @@ async function clearAllGroups() {
failed = true failed = true
addNotification({ addNotification({
type: 'error', type: 'error',
title: 'An error occurred', title: formatMessage(messages.failed),
text: `Failed to clear all groups: ${getErrorMessage(error)}`, text: formatMessage(messages.deleteAllGroupsError, { error: getErrorMessage(error) }),
}) })
} finally { } finally {
isClearing.value = false isClearing.value = false
@@ -252,8 +344,8 @@ async function clearAllGroups() {
if (!failed) { if (!failed) {
addNotification({ addNotification({
type: 'success', type: 'success',
title: 'Success', title: formatMessage(messages.success),
text: 'All groups cleared successfully.', text: formatMessage(messages.deleteAllGroupsSuccess),
}) })
} }
} }
@@ -261,7 +353,7 @@ async function clearAllGroups() {
function show() { function show() {
scanRequestId.value++ scanRequestId.value++
isScanning.value = false isScanning.value = false
rows.value = [] rows.value = {}
void fetchAllVersions() void fetchAllVersions()
modalRef.value?.show() modalRef.value?.show()
} }
@@ -275,9 +367,9 @@ defineExpose({ show, hide })
<template> <template>
<ConfirmModal <ConfirmModal
ref="clearModalRef" ref="clearModalRef"
title="Clear all permission groups?" :title="formatMessage(messages.deleteAllGroupsConfirmationTitle)"
description="This will clear **all** groups for this project. This action cannot be undone." :description="formatMessage(messages.deleteAllGroupsConfirmationDescription)"
proceed-label="Clear" :proceed-label="formatMessage(messages.deleteAllGroupsConfirmationProceed)"
@proceed="clearAllGroups" @proceed="clearAllGroups"
/> />
@@ -294,27 +386,43 @@ defineExpose({ show, hide })
{{ {{
formatMessage(messages.title, { formatMessage(messages.title, {
scanned: scannedCount, scanned: scannedCount,
total: rows.length, total: Object.keys(rows).length,
}) })
}} }}
</span> </span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Combobox
v-model="batchAmount"
:options="batchAmountOptions"
:disabled="titleButtonsDisabled"
:placeholder="formatMessage(messages.batchPlaceholder)"
>
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<span class="truncate text-contrast">{{
formatMessage(messages.batchLabel, { amount: batchAmount })
}}</span>
</span>
</template>
</Combobox>
<ButtonStyled circular color="red" color-fill="none"> <ButtonStyled circular color="red" color-fill="none">
<button <button
v-tooltip="formatMessage(messages.clearAllGroups)" v-tooltip="formatMessage(messages.deleteAllGroups)"
:disabled="isBusy || rows.length === 0" :disabled="titleButtonsDisabled"
@click="showConfirmClearGroups" @click="showConfirmClearGroups"
> >
<TrashIcon aria-hidden="true" /> <TrashIcon v-if="!isClearing" aria-hidden="true" />
<SpinnerIcon v-else class="animate-spin" />
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled circular> <ButtonStyled circular>
<button <button
v-tooltip="formatMessage(messages.scanAllFiles)" v-tooltip="formatMessage(messages.scanAllFiles)"
:disabled="isBusy || rows.length === 0" :disabled="titleButtonsDisabled"
@click="fetchAllScans" @click="fetchAllScans"
> >
<FolderSearchIcon aria-hidden="true" /> <FolderSearchIcon v-if="!isScanning" aria-hidden="true" />
<SpinnerIcon v-else class="animate-spin" />
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
@@ -323,14 +431,14 @@ defineExpose({ show, hide })
<div class="w-full"> <div class="w-full">
<div <div
v-if="versionLoadError || scanError" v-if="versionLoadError || rowScanError"
class="mb-3 rounded-xl bg-highlight-red p-3 text-red" class="mb-3 rounded-xl bg-highlight-red px-4 py-1 text-red"
> >
{{ versionLoadError || scanError }} <div v-html="renderString((versionLoadError || rowScanError) ?? '')"></div>
</div> </div>
<Table <Table
:columns="columns" :columns="columns"
:data="rows" :data="Object.entries(rows).map(([_, row]) => row)"
row-key="id" row-key="id"
:row-below-visible=" :row-below-visible="
(row) => Boolean(row.scan?.scanned_file_names && row.scan.scanned_file_names.length > 0) (row) => Boolean(row.scan?.scanned_file_names && row.scan.scanned_file_names.length > 0)
@@ -345,16 +453,36 @@ defineExpose({ show, hide })
</template> </template>
<template #cell-newFiles="{ row }"> <template #cell-newFiles="{ row }">
<span v-if="row.isScanning">{{ formatMessage(messages.scanning) }}</span> <span v-if="row.isScanning">{{ formatMessage(messages.scanning) }}</span>
<span v-else-if="row.error" v-tooltip="row.error" class="text-red"> <span v-else-if="row.error" v-tooltip="row.error" class="flex justify-center">
{{ formatMessage(messages.failed) }} <ButtonStyled
class="justify-self-center"
color="red"
type="outlined"
hover-color-fill="background"
>
<button :disabled="rescanButtonsDisabled" @click="() => fetchScan(row.id)">
<RotateCounterClockwiseIcon />
{{ formatMessage(messages.failed) }}
</button>
</ButtonStyled>
</span> </span>
<span v-else-if="row.scan">{{ row.scan.new_attribution_files }}</span> <span v-else-if="row.scan">{{ row.scan.new_attribution_files }}</span>
<span v-else>{{ formatMessage(messages.notScanned) }}</span> <span v-else>{{ formatMessage(messages.notScanned) }}</span>
</template> </template>
<template #cell-newGroups="{ row }"> <template #cell-newGroups="{ row }">
<span v-if="row.isScanning">{{ formatMessage(messages.scanning) }}</span> <span v-if="row.isScanning">{{ formatMessage(messages.scanning) }}</span>
<span v-else-if="row.error" v-tooltip="row.error" class="text-red"> <span v-else-if="row.error" v-tooltip="row.error" class="flex justify-center">
{{ formatMessage(messages.failed) }} <ButtonStyled
class="justify-self-center"
color="red"
type="outlined"
hover-color-fill="background"
>
<button :disabled="rescanButtonsDisabled" @click="() => fetchScan(row.id)">
<RotateCounterClockwiseIcon />
{{ formatMessage(messages.failed) }}
</button>
</ButtonStyled>
</span> </span>
<span v-else-if="row.scan">{{ row.scan.new_attribution_groups }}</span> <span v-else-if="row.scan">{{ row.scan.new_attribution_groups }}</span>
<span v-else>{{ formatMessage(messages.notScanned) }}</span> <span v-else>{{ formatMessage(messages.notScanned) }}</span>
@@ -2972,9 +2972,6 @@
"moderation.page.technicalReview": { "moderation.page.technicalReview": {
"message": "Technische Überprüfung" "message": "Technische Überprüfung"
}, },
"modpack-scan-modal.clear-all-groups": {
"message": "Alle Gruppen entfernen"
},
"modpack-scan-modal.failed": { "modpack-scan-modal.failed": {
"message": "Fehlgeschlagen" "message": "Fehlgeschlagen"
}, },
@@ -2972,9 +2972,6 @@
"moderation.page.technicalReview": { "moderation.page.technicalReview": {
"message": "Technische Überprüfung" "message": "Technische Überprüfung"
}, },
"modpack-scan-modal.clear-all-groups": {
"message": "Alle Gruppen entfernen"
},
"modpack-scan-modal.failed": { "modpack-scan-modal.failed": {
"message": "Fehlgeschlagen" "message": "Fehlgeschlagen"
}, },
+21 -3
View File
@@ -2972,8 +2972,23 @@
"moderation.page.technicalReview": { "moderation.page.technicalReview": {
"message": "Tech review" "message": "Tech review"
}, },
"modpack-scan-modal.clear-all-groups": { "modpack-scan-modal.batch.label": {
"message": "Clear All Groups" "message": "{amount} per batch"
},
"modpack-scan-modal.batch.placeholder": {
"message": "Batch amount"
},
"modpack-scan-modal.delete-all-groups-confirmation.description": {
"message": "This will permanently delete all attribution groups for this project and all files inside them. This action cannot be undone."
},
"modpack-scan-modal.delete-all-groups.error": {
"message": "Failed to clear all groups: {error}"
},
"modpack-scan-modal.delete-all-groups.proceed": {
"message": "Clear"
},
"modpack-scan-modal.delete-all-groups.success": {
"message": "All groups cleared successfully."
}, },
"modpack-scan-modal.failed": { "modpack-scan-modal.failed": {
"message": "Failed" "message": "Failed"
@@ -3006,11 +3021,14 @@
"message": "Scan All Files" "message": "Scan All Files"
}, },
"modpack-scan-modal.scan-error": { "modpack-scan-modal.scan-error": {
"message": "Some files failed to scan: {error}" "message": "Some files failed to scan: \n\n{error}"
}, },
"modpack-scan-modal.scanning": { "modpack-scan-modal.scanning": {
"message": "Scanning..." "message": "Scanning..."
}, },
"modpack-scan-modal.success": {
"message": "Success"
},
"modpack-scan-modal.title": { "modpack-scan-modal.title": {
"message": "Modpack Scan ({scanned}/{total} Files)" "message": "Modpack Scan ({scanned}/{total} Files)"
}, },
@@ -2954,9 +2954,6 @@
"moderation.page.technicalReview": { "moderation.page.technicalReview": {
"message": "Revue technique" "message": "Revue technique"
}, },
"modpack-scan-modal.clear-all-groups": {
"message": "Effacer tous les groupes"
},
"modpack-scan-modal.failed": { "modpack-scan-modal.failed": {
"message": "Échec" "message": "Échec"
}, },
@@ -2957,9 +2957,6 @@
"moderation.page.technicalReview": { "moderation.page.technicalReview": {
"message": "Revisione tecnica" "message": "Revisione tecnica"
}, },
"modpack-scan-modal.clear-all-groups": {
"message": "Svuota gruppi"
},
"modpack-scan-modal.failed": { "modpack-scan-modal.failed": {
"message": "Errore" "message": "Errore"
}, },
@@ -2942,9 +2942,6 @@
"moderation.page.technicalReview": { "moderation.page.technicalReview": {
"message": "기술 리뷰" "message": "기술 리뷰"
}, },
"modpack-scan-modal.clear-all-groups": {
"message": "모든 그룹 삭제"
},
"modpack-scan-modal.failed": { "modpack-scan-modal.failed": {
"message": "실패" "message": "실패"
}, },
@@ -2939,9 +2939,6 @@
"moderation.page.technicalReview": { "moderation.page.technicalReview": {
"message": "Technische beoordeling" "message": "Technische beoordeling"
}, },
"modpack-scan-modal.clear-all-groups": {
"message": "Alle groepen wissen"
},
"modpack-scan-modal.failed": { "modpack-scan-modal.failed": {
"message": "Mislukt" "message": "Mislukt"
}, },
@@ -2942,9 +2942,6 @@
"moderation.page.technicalReview": { "moderation.page.technicalReview": {
"message": "Revisão técnica" "message": "Revisão técnica"
}, },
"modpack-scan-modal.clear-all-groups": {
"message": "Limpar todos os grupos"
},
"modpack-scan-modal.failed": { "modpack-scan-modal.failed": {
"message": "Falhou" "message": "Falhou"
}, },
@@ -2960,9 +2960,6 @@
"moderation.page.technicalReview": { "moderation.page.technicalReview": {
"message": "Техпроверка" "message": "Техпроверка"
}, },
"modpack-scan-modal.clear-all-groups": {
"message": "Очистить все группы"
},
"modpack-scan-modal.failed": { "modpack-scan-modal.failed": {
"message": "Ошибка" "message": "Ошибка"
}, },
@@ -2936,9 +2936,6 @@
"moderation.page.technicalReview": { "moderation.page.technicalReview": {
"message": "Технічний огляд" "message": "Технічний огляд"
}, },
"modpack-scan-modal.clear-all-groups": {
"message": "Очистити всі групи"
},
"modpack-scan-modal.failed": { "modpack-scan-modal.failed": {
"message": "Помилка" "message": "Помилка"
}, },
@@ -2969,9 +2969,6 @@
"moderation.page.technicalReview": { "moderation.page.technicalReview": {
"message": "技术审查" "message": "技术审查"
}, },
"modpack-scan-modal.clear-all-groups": {
"message": "清除所有分组"
},
"modpack-scan-modal.failed": { "modpack-scan-modal.failed": {
"message": "失败" "message": "失败"
}, },
@@ -2948,9 +2948,6 @@
"moderation.page.technicalReview": { "moderation.page.technicalReview": {
"message": "技術審查" "message": "技術審查"
}, },
"modpack-scan-modal.clear-all-groups": {
"message": "清除所有群組"
},
"modpack-scan-modal.failed": { "modpack-scan-modal.failed": {
"message": "失敗" "message": "失敗"
}, },