feat: tech review global trace database (#6648)

* global detail verdicts system

* global traces frontend

* split global routes into own mod

* tweak migration to only do no-key check at app level

* prepr
This commit is contained in:
aecsocket
2026-07-08 16:06:45 +00:00
committed by GitHub
parent 9006dce2b0
commit c84b658e41
29 changed files with 1763 additions and 134 deletions
@@ -0,0 +1,63 @@
<template>
<div class="rounded-lg border border-divider bg-bg-raised p-3">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<p class="m-0 break-words font-semibold text-contrast">
{{ trace.project_name }}
</p>
<p class="m-0 mt-1 break-all text-sm text-secondary">
Project {{ trace.project_slug ?? trace.project_id }} / Version
{{ trace.version_number }} / File {{ trace.file_name }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm text-secondary">Local</span>
<Badge :type="trace.local_status" />
<span class="text-sm text-secondary">Effective</span>
<Badge :type="trace.effective_status" />
<ButtonStyled>
<NuxtLink :to="localTraceLink">
<ExternalIcon aria-hidden="true" />
View
</NuxtLink>
</ButtonStyled>
</div>
</div>
<div class="mt-3 grid gap-2 text-sm text-secondary md:grid-cols-2">
<p class="m-0 break-all">
<span class="font-semibold text-contrast">Issue</span>
{{ trace.issue_type }}
</p>
<p class="m-0 break-all">
<span class="font-semibold text-contrast">Severity</span>
{{ trace.severity }}
</p>
<p class="m-0 break-all">
<span class="font-semibold text-contrast">Path</span>
{{ trace.file_path }}
</p>
<p v-if="trace.jar" class="m-0 break-all">
<span class="font-semibold text-contrast">JAR</span>
{{ trace.jar }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ExternalIcon } from '@modrinth/assets'
import { Badge, ButtonStyled } from '@modrinth/ui'
const props = defineProps<{
trace: Labrinth.TechReview.Internal.GlobalIssueDetailTrace
}>()
const localTraceLink = computed(
() =>
`/moderation/technical-review/${props.trace.project_id}?detail=${encodeURIComponent(
props.trace.detail_id,
)}`,
)
</script>
@@ -0,0 +1,180 @@
<template>
<div>
<form class="flex flex-col gap-2 sm:flex-row" @submit.prevent="executeSearch">
<StyledInput
v-model="query"
:icon="SearchIcon"
type="text"
autocomplete="off"
placeholder="Search global trace keys..."
clearable
wrapper-class="flex-1 w-full"
/>
<ButtonStyled color="brand">
<button type="submit" :disabled="isLoading">
<SearchIcon aria-hidden="true" />
Search
</button>
</ButtonStyled>
</form>
<div
v-if="!isLoading && !loadError && total > 0"
class="mt-4 flex flex-wrap items-center justify-between gap-3"
>
<p class="m-0 text-sm text-secondary">Showing {{ pageStart }}-{{ pageEnd }} of {{ total }}</p>
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
</div>
<EmptyState
v-if="isLoading"
type="no-search-result"
heading="Loading global detail traces..."
/>
<EmptyState
v-else-if="loadError"
type="no-search-result"
heading="Failed to load global detail traces"
/>
<div v-else-if="traces.length > 0" class="mt-4 flex flex-col gap-3">
<article
v-for="trace in traces"
:key="trace.detail_key"
class="universal-card flex flex-col gap-3"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex min-w-0 items-center gap-2">
<HashIcon class="shrink-0 text-secondary" aria-hidden="true" />
<h2 class="m-0 min-w-0 text-lg font-semibold text-contrast">
Trace
<span class="break-all font-mono text-base">{{ trace.detail_key }}</span>
</h2>
</div>
<p class="m-0 mt-1 text-sm text-secondary">
{{ formatTraceCount(trace.local_trace_count) }}
</p>
</div>
<Badge :type="trace.verdict" />
</div>
<div v-if="getPreviewLocalTraces(trace).length > 0" class="flex flex-col gap-2">
<div
v-if="getVisibleLocalTraceTotal(trace) > getPreviewLocalTraces(trace).length"
class="flex flex-wrap items-center justify-between gap-2"
>
<p class="m-0 text-sm text-secondary">
Showing first {{ getPreviewLocalTraces(trace).length }} of
{{ getVisibleLocalTraceTotal(trace) }} local traces
</p>
<ButtonStyled>
<NuxtLink :to="getGlobalTraceLink(trace)">
<ListIcon aria-hidden="true" />
View all
</NuxtLink>
</ButtonStyled>
</div>
<GlobalDetailLocalTraceCard
v-for="localTrace in getPreviewLocalTraces(trace)"
:key="localTrace.detail_id"
:trace="localTrace"
/>
</div>
<EmptyState
v-else
type="no-search-result"
heading="No local traces currently match this key"
/>
</article>
</div>
<EmptyState v-else type="no-search-result" heading="No global detail traces found" />
<div v-if="!isLoading && !loadError && total > 0" class="mt-4 flex justify-end">
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { HashIcon, ListIcon, SearchIcon } from '@modrinth/assets'
import {
Badge,
ButtonStyled,
EmptyState,
injectModrinthClient,
Pagination,
StyledInput,
} from '@modrinth/ui'
import GlobalDetailLocalTraceCard from '~/components/ui/moderation/GlobalDetailLocalTraceCard.vue'
const client = injectModrinthClient()
const query = ref('')
const activeQuery = ref<string | null>(null)
const isLoading = ref(false)
const loadError = ref(false)
const currentPage = ref(1)
const itemsPerPage = 20
const localTracePreviewLimit = 10
const total = ref(0)
const traces = ref<Labrinth.TechReview.Internal.GlobalIssueDetail[]>([])
const pageCount = computed(() => Math.max(Math.ceil(total.value / itemsPerPage), 1))
const pageStart = computed(() =>
total.value === 0 ? 0 : (currentPage.value - 1) * itemsPerPage + 1,
)
const pageEnd = computed(() => Math.min(currentPage.value * itemsPerPage, total.value))
function formatTraceCount(count: number) {
return `${count} local ${count === 1 ? 'trace' : 'traces'}`
}
function getPreviewLocalTraces(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
return trace.local_traces.slice(0, localTracePreviewLimit)
}
function getVisibleLocalTraceTotal(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
return Math.max(trace.local_trace_count, trace.local_traces.length)
}
function getGlobalTraceLink(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
return `/moderation/global-traces/${encodeURIComponent(trace.detail_key)}`
}
async function loadTraces() {
isLoading.value = true
loadError.value = false
try {
const response = await client.labrinth.tech_review_internal.searchGlobalIssueDetails({
query: activeQuery.value,
limit: itemsPerPage,
page: currentPage.value - 1,
})
traces.value = response.traces
total.value = response.total
} catch (error) {
console.error('Failed to load global detail traces', error)
traces.value = []
total.value = 0
loadError.value = true
} finally {
isLoading.value = false
}
}
async function executeSearch() {
activeQuery.value = query.value.trim() || null
currentPage.value = 1
await loadTraces()
}
async function switchPage(page: number) {
currentPage.value = page
await loadTraces()
}
onMounted(loadTraces)
</script>
@@ -45,7 +45,7 @@ import {
type User,
} from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed, reactive, ref, watch } from 'vue'
import { computed, nextTick, reactive, ref, watch } from 'vue'
import type { UnsafeFile } from '~/components/ui/moderation/MaliciousSummaryModal.vue'
import ThreadView from '~/components/ui/thread/ThreadView.vue'
@@ -89,6 +89,7 @@ const props = defineProps<{
thread: Labrinth.TechReview.Internal.Thread
reports: FlattenedFileReport[]
}
focusedDetailId?: string | null
loadingIssues: Set<string>
decompiledSources: Map<string, string>
}>()
@@ -192,7 +193,12 @@ watch(selectedFile, (newFile) => {
const client = injectModrinthClient()
async function updateIssueDetails(data: { detail_id: string; verdict: 'safe' | 'unsafe' }[]) {
async function updateIssueDetails(
data: {
detail_id: string
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus
}[],
) {
await client.request('/moderation/tech-review/issue-detail', {
api: 'labrinth',
version: 'internal',
@@ -381,6 +387,49 @@ function viewFileFlags(file: FlattenedFileReport) {
currentTab.value = 'File'
}
function getDetailElementId(detailId: string) {
return `tech-review-detail-${detailId}`
}
function findFileForDetail(detailId: string): FlattenedFileReport | null {
for (const report of props.item.reports) {
for (const issue of report.issues) {
if (issue.details.some((detail) => detail.id === detailId)) {
return report
}
}
}
return null
}
async function focusDetail(detailId: string) {
const file = findFileForDetail(detailId)
if (!file) return
viewFileFlags(file)
await nextTick()
const classItem = groupedByClass.value.find((group) =>
group.flags.some((flag) => flag.detail.id === detailId),
)
if (classItem) {
expandClass(classItem)
}
await nextTick()
if (!import.meta.client) return
window.requestAnimationFrame(() => {
document.getElementById(getDetailElementId(detailId))?.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
})
}
function backToFileList() {
selectedFileId.value = null
if (currentTab.value === 'File') {
@@ -691,6 +740,16 @@ const groupedByJar = computed<JarGroup[]>(() => {
})
})
watch(
() => props.focusedDetailId,
(detailId) => {
if (detailId) {
focusDetail(detailId)
}
},
{ immediate: true },
)
// Auto-expand/load source for small files; keep larger files lazy.
watch(
[selectedFileId, groupedByClass],
@@ -1384,8 +1443,12 @@ function copyId() {
>
<div
v-for="flag in classItem.flags"
:id="getDetailElementId(flag.detail.id)"
:key="`${flag.issueId}-${flag.detail.id}`"
class="flex flex-col gap-2 rounded-lg border-[1px] border-b border-solid border-surface-5 bg-surface-3 py-2 pl-4 last:border-b-0"
:class="{
'!border-brand bg-brand-highlight': props.focusedDetailId === flag.detail.id,
}"
>
<div class="grid grid-cols-[1fr_auto] items-center">
<div
@@ -2681,6 +2681,9 @@
"moderation.page.external-projects": {
"message": "External projects"
},
"moderation.page.global-detail-traces": {
"message": "Global traces"
},
"moderation.page.projects": {
"message": "Projects"
},
+21 -3
View File
@@ -15,7 +15,7 @@
</template>
<script setup lang="ts">
import { FolderIcon, GlobeIcon, ReportIcon, ShieldCheckIcon } from '@modrinth/assets'
import { FolderIcon, GlobeIcon, HashIcon, ReportIcon, ShieldCheckIcon } from '@modrinth/assets'
import { Chips, defineMessages, NavTabs, useVIntl } from '@modrinth/ui'
definePageMeta({
@@ -47,6 +47,10 @@ const messages = defineMessages({
id: 'moderation.page.external-projects',
defaultMessage: 'External projects',
},
globalDetailTracesTitle: {
id: 'moderation.page.global-detail-traces',
defaultMessage: 'Global traces',
},
})
const moderationLinks = [
@@ -62,6 +66,11 @@ const moderationLinks = [
href: '/moderation/external-projects',
icon: GlobeIcon,
},
{
label: formatMessage(messages.globalDetailTracesTitle),
href: '/moderation/global-traces',
icon: HashIcon,
},
]
const mobileNavOptions = [
@@ -69,15 +78,20 @@ const mobileNavOptions = [
formatMessage(messages.technicalReviewTitle),
formatMessage(messages.reportsTitle),
formatMessage(messages.externalFilesTitle),
formatMessage(messages.globalDetailTracesTitle),
]
const selectedChip = computed({
get() {
const path = route.path
if (path === '/moderation/technical-review') {
if (path.startsWith('/moderation/technical-review')) {
return formatMessage(messages.technicalReviewTitle)
} else if (path.startsWith('/moderation/reports/')) {
} else if (path.startsWith('/moderation/reports')) {
return formatMessage(messages.reportsTitle)
} else if (path.startsWith('/moderation/external-projects')) {
return formatMessage(messages.externalFilesTitle)
} else if (path.startsWith('/moderation/global-traces')) {
return formatMessage(messages.globalDetailTracesTitle)
} else {
return formatMessage(messages.projectsTitle)
}
@@ -92,6 +106,10 @@ function navigateToPage(selectedOption: string) {
router.push('/moderation/technical-review')
} else if (selectedOption === formatMessage(messages.reportsTitle)) {
router.push('/moderation/reports')
} else if (selectedOption === formatMessage(messages.externalFilesTitle)) {
router.push('/moderation/external-projects')
} else if (selectedOption === formatMessage(messages.globalDetailTracesTitle)) {
router.push('/moderation/global-traces')
} else {
router.push('/moderation')
}
@@ -0,0 +1,153 @@
<template>
<div class="flex flex-col gap-4">
<div>
<ButtonStyled>
<NuxtLink to="/moderation/global-traces">
<ArrowLeftIcon aria-hidden="true" />
Back to global traces
</NuxtLink>
</ButtonStyled>
</div>
<EmptyState
v-if="isLoading && !trace"
type="no-search-result"
heading="Loading global detail trace..."
/>
<EmptyState
v-else-if="loadError"
type="no-search-result"
heading="Failed to load global detail trace"
/>
<article v-else-if="trace" class="universal-card flex flex-col gap-3">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex min-w-0 items-center gap-2">
<HashIcon class="shrink-0 text-secondary" aria-hidden="true" />
<h2 class="m-0 min-w-0 text-lg font-semibold text-contrast">
Trace
<span class="break-all font-mono text-base">{{ trace.detail_key }}</span>
</h2>
</div>
<p class="m-0 mt-1 text-sm text-secondary">
{{ pageStart }}-{{ pageEnd }} of {{ trace.local_trace_count }} local traces
</p>
</div>
<Badge :type="trace.verdict" />
</div>
<div
v-if="trace.local_trace_count > localTracePageSize"
class="flex flex-wrap items-center justify-between gap-3"
>
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
<p v-if="isLoading" class="m-0 text-sm text-secondary">Loading page...</p>
</div>
<div v-if="trace.local_traces.length > 0" class="flex flex-col gap-2">
<GlobalDetailLocalTraceCard
v-for="localTrace in trace.local_traces"
:key="localTrace.detail_id"
:trace="localTrace"
/>
</div>
<EmptyState v-else type="no-search-result" heading="No local traces match this key" />
<div v-if="trace.local_trace_count > localTracePageSize" class="mt-1 flex justify-end">
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
</div>
</article>
<EmptyState v-else type="no-search-result" heading="Global detail trace not found" />
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ArrowLeftIcon, HashIcon } from '@modrinth/assets'
import { Badge, ButtonStyled, EmptyState, injectModrinthClient, Pagination } from '@modrinth/ui'
import GlobalDetailLocalTraceCard from '~/components/ui/moderation/GlobalDetailLocalTraceCard.vue'
const client = injectModrinthClient()
const route = useRoute()
const detailKey = computed(() => {
const key = route.params.key
return Array.isArray(key) ? key.join('/') : String(key)
})
useHead({ title: () => `Global trace - ${detailKey.value} - Modrinth` })
const localTracePageSize = 20
const isLoading = ref(false)
const loadError = ref(false)
const currentPage = ref(1)
const pageStartCursors = ref<(string | null)[]>([null])
const trace = ref<Labrinth.TechReview.Internal.GlobalIssueDetail | null>(null)
const pageCount = computed(() =>
Math.max(Math.ceil((trace.value?.local_trace_count ?? 0) / localTracePageSize), 1),
)
const pageStart = computed(() =>
trace.value && trace.value.local_trace_count > 0
? (currentPage.value - 1) * localTracePageSize + 1
: 0,
)
const pageEnd = computed(() =>
Math.min(currentPage.value * localTracePageSize, trace.value?.local_trace_count ?? 0),
)
async function fetchTracePage(afterDetailId: string | null) {
return await client.labrinth.tech_review_internal.getGlobalIssueDetail({
detail_key: detailKey.value,
limit: localTracePageSize,
after_detail_id: afterDetailId,
})
}
async function loadPage(page: number) {
if (page < 1 || isLoading.value) return
isLoading.value = true
loadError.value = false
try {
while (pageStartCursors.value.length < page) {
const cursor = pageStartCursors.value[pageStartCursors.value.length - 1]
const response = await fetchTracePage(cursor)
if (!response.next_after_detail_id) {
trace.value = response.trace
currentPage.value = pageStartCursors.value.length
return
}
pageStartCursors.value.push(response.next_after_detail_id)
}
const response = await fetchTracePage(pageStartCursors.value[page - 1])
trace.value = response.trace
currentPage.value = page
} catch (error) {
console.error('Failed to load global detail trace', error)
loadError.value = true
} finally {
isLoading.value = false
}
}
async function switchPage(page: number) {
await loadPage(page)
}
watch(
detailKey,
() => {
currentPage.value = 1
pageStartCursors.value = [null]
trace.value = null
loadPage(1)
},
{ immediate: true },
)
</script>
@@ -0,0 +1,9 @@
<template>
<GlobalDetailTracesList />
</template>
<script setup lang="ts">
import GlobalDetailTracesList from '~/components/ui/moderation/GlobalDetailTracesList.vue'
useHead({ title: 'Global detail traces - Modrinth' })
</script>
@@ -11,6 +11,7 @@ import ModerationTechRevCard from '~/components/ui/moderation/ModerationTechRevC
const client = injectModrinthClient()
const queryClient = useQueryClient()
const route = useRoute()
const projectId = String(useRouteId('project'))
@@ -245,6 +246,8 @@ const reviewItem = computed(() => {
}
})
const focusedDetailId = computed(() => route.query.detail?.toString() ?? null)
async function handleMarkComplete(projectId: string) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['tech-reviews'] }),
@@ -299,6 +302,7 @@ function refetch() {
<ModerationTechRevCard
v-else
:item="reviewItem"
:focused-detail-id="focusedDetailId"
:loading-issues="loadingIssues"
:decompiled-sources="decompiledSources"
@refetch="refetch"