feat: global trace database frontend + improvements to tech review status sync (#6671)

* wip

* ui edits

* better logic for syncing tech review state in a single centralized place

* never automatically finish a tech review report

* fix css

* fix clippy

* prepr
This commit is contained in:
aecsocket
2026-07-09 16:09:02 +00:00
committed by GitHub
parent 623b51e6ea
commit e877167db7
29 changed files with 1369 additions and 396 deletions
@@ -55,7 +55,18 @@
{{ formatTraceCount(trace.local_trace_count) }}
</p>
</div>
<Badge :type="trace.verdict" />
<div class="flex shrink-0 flex-wrap items-center gap-2">
<Badge :type="trace.verdict" />
<ButtonStyled color="red">
<button
:disabled="removingTraceKeys.has(trace.detail_key)"
@click="removeGlobalTrace(trace)"
>
<TrashIcon aria-hidden="true" />
Remove
</button>
</ButtonStyled>
</div>
</div>
<div v-if="getPreviewLocalTraces(trace).length > 0" class="flex flex-col gap-2">
@@ -97,12 +108,13 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { HashIcon, ListIcon, SearchIcon } from '@modrinth/assets'
import { HashIcon, ListIcon, SearchIcon, TrashIcon } from '@modrinth/assets'
import {
Badge,
ButtonStyled,
EmptyState,
injectModrinthClient,
injectNotificationManager,
Pagination,
StyledInput,
} from '@modrinth/ui'
@@ -110,6 +122,7 @@ import {
import GlobalDetailLocalTraceCard from '~/components/ui/moderation/GlobalDetailLocalTraceCard.vue'
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const query = ref('')
const activeQuery = ref<string | null>(null)
const isLoading = ref(false)
@@ -119,6 +132,7 @@ const itemsPerPage = 20
const localTracePreviewLimit = 10
const total = ref(0)
const traces = ref<Labrinth.TechReview.Internal.GlobalIssueDetail[]>([])
const removingTraceKeys = reactive<Set<string>>(new Set())
const pageCount = computed(() => Math.max(Math.ceil(total.value / itemsPerPage), 1))
const pageStart = computed(() =>
@@ -176,5 +190,37 @@ async function switchPage(page: number) {
await loadTraces()
}
async function removeGlobalTrace(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
if (removingTraceKeys.has(trace.detail_key)) return
removingTraceKeys.add(trace.detail_key)
try {
await client.labrinth.tech_review_internal.updateGlobalIssueDetails([
{ detail_key: trace.detail_key, verdict: 'pending' },
])
addNotification({
type: 'success',
title: 'Global trace removed',
text: 'The global verdict for this trace key has been removed.',
})
if (traces.value.length === 1 && currentPage.value > 1) {
currentPage.value--
}
await loadTraces()
} catch (error) {
console.error('Failed to remove global trace', error)
addNotification({
type: 'error',
title: 'Failed to remove global trace',
text: 'An error occurred while removing the global trace verdict.',
})
} finally {
removingTraceKeys.delete(trace.detail_key)
}
}
onMounted(loadTraces)
</script>
@@ -1,7 +1,9 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
BanIcon,
BugIcon,
CheckCheckIcon,
CheckCircleIcon,
CheckIcon,
ChevronDownIcon,
@@ -14,6 +16,7 @@ import {
EyeOffIcon,
LoaderCircleIcon,
ScaleIcon,
ShieldAlertIcon,
ShieldCheckIcon,
SpinnerIcon,
TimerIcon,
@@ -207,15 +210,31 @@ async function updateIssueDetails(
})
}
async function updateGlobalIssueDetail(
detailKey: string,
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
) {
await client.labrinth.tech_review_internal.updateGlobalIssueDetails([
{ detail_key: detailKey, verdict },
])
}
const severityOrder = { severe: 3, high: 2, medium: 1, low: 0 } as Record<string, number>
type DetailDecision = 'safe' | 'malware'
type DetailDecision = 'safe' | 'malware' | 'pending'
type DetailDecisionScope = 'local' | 'global'
const detailDecisions = reactive<Map<string, DetailDecision>>(new Map())
const detailDecisionScopes = reactive<Map<string, DetailDecisionScope>>(new Map())
const updatingDetails = reactive<Set<string>>(new Set())
const updatingGlobalDetailKeys = reactive<Set<string>>(new Set())
function verdictToDecision(verdict: 'safe' | 'unsafe'): DetailDecision {
return verdict === 'safe' ? 'safe' : 'malware'
function verdictToDecision(
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
): DetailDecision {
if (verdict === 'safe') return 'safe'
if (verdict === 'unsafe') return 'malware'
return 'pending'
}
function getAllDetails(): Labrinth.TechReview.Internal.ReportIssueDetail[] {
@@ -225,6 +244,7 @@ function getAllDetails(): Labrinth.TechReview.Internal.ReportIssueDetail[] {
function applyDecisionToRelatedDetails(
detailIds: string[],
decision: DetailDecision,
scope: DetailDecisionScope,
): { otherMatchedCount: number } {
const allDetails = getAllDetails()
const selectedDetailIds = new Set(detailIds)
@@ -242,12 +262,14 @@ function applyDecisionToRelatedDetails(
if (matchingDetails.length === 0) {
detailDecisions.set(detailId, decision)
detailDecisionScopes.set(detailId, scope)
updatedDetailIds.add(detailId)
continue
}
for (const matchingDetail of matchingDetails) {
detailDecisions.set(matchingDetail.id, decision)
detailDecisionScopes.set(matchingDetail.id, scope)
updatedDetailIds.add(matchingDetail.id)
}
}
@@ -258,6 +280,98 @@ function applyDecisionToRelatedDetails(
}
}
function statusMatchesDecision(
status: Labrinth.TechReview.Internal.DelphiReportIssueStatus | null,
decision: DetailDecision,
): boolean {
if (status === 'safe') return decision === 'safe'
if (status === 'unsafe') return decision === 'malware'
return false
}
function isDetailActionSelected(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
decision: DetailDecision,
scope: DetailDecisionScope,
): boolean {
const localDecision = detailDecisions.get(detail.id)
const localScope = detailDecisionScopes.get(detail.id)
if (localDecision && localScope) {
if (localDecision === 'pending') {
if (localScope === 'local') {
if (scope === 'local') return false
return statusMatchesDecision(detail.global_status, decision)
}
if (scope === 'global') return false
return statusMatchesDecision(detail.local_status, decision)
}
return localDecision === decision && localScope === scope
}
if (scope === 'global') {
return statusMatchesDecision(detail.global_status, decision)
}
if (detail.global_status) {
return false
}
return statusMatchesDecision(detail.local_status, decision)
}
function decisionToVerdict(
decision: Exclude<DetailDecision, 'pending'>,
): Labrinth.TechReview.Internal.DelphiReportIssueStatus {
return decision === 'safe' ? 'safe' : 'unsafe'
}
function getToggledDetailVerdict(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
decision: Exclude<DetailDecision, 'pending'>,
scope: DetailDecisionScope,
): Labrinth.TechReview.Internal.DelphiReportIssueStatus {
return isDetailActionSelected(detail, decision, scope) ? 'pending' : decisionToVerdict(decision)
}
function getDetailActionTooltip(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
decision: Exclude<DetailDecision, 'pending'>,
scope: DetailDecisionScope,
): string {
const action = decision === 'safe' ? 'pass' : 'fail'
const scopeLabel = scope === 'global' ? 'Global' : 'Local'
if (scope === 'global' && !canUpdateGlobalDetail(detail)) {
return 'Global verdict unavailable for generated trace keys'
}
if (isDetailActionSelected(detail, decision, scope)) {
return `Unset ${scopeLabel.toLowerCase()} ${action}`
}
return `${scopeLabel} ${action}`
}
function updateLocalDetailAction(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
decision: Exclude<DetailDecision, 'pending'>,
) {
return updateDetailStatus(detail.id, getToggledDetailVerdict(detail, decision, 'local'))
}
function updateGlobalDetailAction(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
decision: Exclude<DetailDecision, 'pending'>,
) {
return updateGlobalDetailStatus(detail, getToggledDetailVerdict(detail, decision, 'global'))
}
function canUpdateGlobalDetail(detail: Labrinth.TechReview.Internal.ReportIssueDetail): boolean {
return detail.key.length > 0 && !detail.key.startsWith('<no-key-')
}
function getFileHighestSeverity(
file: FlattenedFileReport,
): Labrinth.TechReview.Internal.DelphiSeverity {
@@ -495,6 +609,18 @@ const remainingUnmarkedCount = computed(() => {
return getFileDetailCount(selectedFile.value) - getFileMarkedCount(selectedFile.value)
})
function getJarFlags(jarGroup: JarGroup): ClassGroup['flags'] {
return jarGroup.classes.flatMap((classItem) => classItem.flags)
}
function getJarMarkedCount(jarGroup: JarGroup): number {
return getMarkedFlagsCount(getJarFlags(jarGroup))
}
function getJarRemainingUnmarkedCount(jarGroup: JarGroup): number {
return getJarFlags(jarGroup).length - getJarMarkedCount(jarGroup)
}
const isBatchUpdating = ref(false)
async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
@@ -518,7 +644,7 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
try {
await updateIssueDetails(detailIds.map((detailId) => ({ detail_id: detailId, verdict })))
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict))
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict), 'local')
addNotification({
type: 'success',
@@ -548,7 +674,54 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
}
}
async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe') {
async function batchMarkRemainingInJar(jarGroup: JarGroup, verdict: 'safe' | 'unsafe') {
if (isBatchUpdating.value) return
const detailIds = getJarFlags(jarGroup)
.filter((flag) => getDetailDecision(flag.detail.id, flag.detail.status) === 'pending')
.map((flag) => flag.detail.id)
if (detailIds.length === 0) return
isBatchUpdating.value = true
try {
await updateIssueDetails(detailIds.map((detailId) => ({ detail_id: detailId, verdict })))
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict), 'local')
addNotification({
type: 'success',
title: `Marked ${detailIds.length} traces as ${verdict}`,
text: `All remaining traces in this JAR have been marked as ${
verdict === 'safe' ? 'false positives' : 'malicious'
}.`,
})
if (selectedFile.value) {
const markedCount = getFileMarkedCount(selectedFile.value)
const totalCount = getFileDetailCount(selectedFile.value)
if (markedCount === totalCount) {
backToFileList()
}
}
emit('refetch')
} catch (error) {
console.error('Failed to batch update JAR traces:', error)
addNotification({
type: 'error',
title: 'Batch update failed',
text: 'An error occurred while updating JAR traces.',
})
} finally {
isBatchUpdating.value = false
}
}
async function updateDetailStatus(
detailId: string,
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
) {
let priorDecision: 'safe' | 'malware' | 'pending' = 'pending'
outer: for (const report of props.item.reports) {
for (const issue of report.issues) {
@@ -568,10 +741,11 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
const { otherMatchedCount } = applyDecisionToRelatedDetails(
[detailId],
verdictToDecision(verdict),
'local',
)
// Only collapse if the prior state was 'pending' (new decision, not updating existing)
if (priorDecision === 'pending') {
if (verdict !== 'pending' && priorDecision === 'pending') {
for (const classGroup of groupedByClass.value) {
const hasThisDetail = classGroup.flags.some((f) => f.detail.id === detailId)
if (hasThisDetail && getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) {
@@ -582,7 +756,7 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
}
// Jump back to Files tab when all flags in the current file are marked
if (selectedFile.value) {
if (verdict !== 'pending' && selectedFile.value) {
const markedCount = getFileMarkedCount(selectedFile.value)
const totalCount = getFileDetailCount(selectedFile.value)
if (markedCount === totalCount) {
@@ -595,7 +769,13 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
? ` (${otherMatchedCount} other trace${otherMatchedCount === 1 ? '' : 's'} also marked)`
: ''
if (verdict === 'safe') {
if (verdict === 'pending') {
addNotification({
type: 'success',
title: 'Local trace verdict unset',
text: `The project-local verdict has been removed.${otherText}`,
})
} else if (verdict === 'safe') {
addNotification({
type: 'success',
title: 'Issue marked as pass',
@@ -622,6 +802,82 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
}
}
async function updateGlobalDetailStatus(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
) {
if (!canUpdateGlobalDetail(detail)) {
addNotification({
type: 'error',
title: 'Global update unavailable',
text: 'Generated trace keys cannot be marked globally.',
})
return
}
updatingGlobalDetailKeys.add(detail.key)
try {
await updateGlobalIssueDetail(detail.key, verdict)
const { otherMatchedCount } = applyDecisionToRelatedDetails(
[detail.id],
verdictToDecision(verdict),
'global',
)
if (verdict !== 'pending') {
for (const classGroup of groupedByClass.value) {
if (getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) {
expandedClasses.delete(classGroup.key)
}
}
}
if (verdict !== 'pending' && selectedFile.value) {
const markedCount = getFileMarkedCount(selectedFile.value)
const totalCount = getFileDetailCount(selectedFile.value)
if (markedCount === totalCount) {
backToFileList()
}
}
const otherText =
otherMatchedCount > 0
? ` (${otherMatchedCount} other trace${otherMatchedCount === 1 ? '' : 's'} also marked in this project)`
: ''
if (verdict === 'pending') {
addNotification({
type: 'success',
title: 'Global trace verdict unset',
text: `The global verdict for this trace key has been removed.${otherText}`,
})
} else {
addNotification({
type: 'success',
title:
verdict === 'safe' ? 'Trace globally marked as pass' : 'Trace globally marked as fail',
text:
verdict === 'safe'
? `This trace key has been marked as a global false positive.${otherText}`
: `This trace key has been globally flagged as malicious.${otherText}`,
})
}
emit('refetch')
} catch (error) {
console.error('Failed to update global detail status:', error)
addNotification({
type: 'error',
title: 'Failed to update global trace',
text: 'An error occurred while updating the global trace status.',
})
} finally {
updatingGlobalDetailKeys.delete(detail.key)
}
}
const expandedClasses = reactive<Set<string>>(new Set())
const autoExpandedFileIds = reactive<Set<string>>(new Set())
const showCopyFeedback = reactive<Map<string, boolean>>(new Map())
@@ -1352,26 +1608,49 @@ function copyId() {
v-if="jarGroup.segments.length > 0"
class="border-b border-solid border-surface-1 px-4 py-3"
>
<div class="flex flex-wrap items-center gap-1">
<template
v-for="(segment, index) in jarGroup.segments"
:key="`${jarGroup.key}-${index}`"
>
<span
class="font-mono text-sm"
:class="
index === jarGroup.segments.length - 1
? 'font-semibold text-contrast'
: 'text-secondary'
"
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-1">
<template
v-for="(segment, index) in jarGroup.segments"
:key="`${jarGroup.key}-${index}`"
>
{{ segment }}
</span>
<ChevronRightIcon
v-if="index < jarGroup.segments.length - 1"
class="size-4 text-secondary"
/>
</template>
<span
class="font-mono text-sm"
:class="
index === jarGroup.segments.length - 1
? 'font-semibold text-contrast'
: 'text-secondary'
"
>
{{ segment }}
</span>
<ChevronRightIcon
v-if="index < jarGroup.segments.length - 1"
class="size-4 text-secondary"
/>
</template>
</div>
<div v-if="getJarRemainingUnmarkedCount(jarGroup) > 0" class="flex gap-2">
<ButtonStyled color="brand" size="small">
<button
:disabled="isBatchUpdating"
@click="batchMarkRemainingInJar(jarGroup, 'safe')"
>
<CheckCircleIcon class="size-4" />
Remaining safe ({{ getJarRemainingUnmarkedCount(jarGroup) }})
</button>
</ButtonStyled>
<ButtonStyled color="red" size="small">
<button
:disabled="isBatchUpdating"
@click="batchMarkRemainingInJar(jarGroup, 'unsafe')"
>
<TriangleAlertIcon class="size-4" />
Remaining malware ({{ getJarRemainingUnmarkedCount(jarGroup) }})
</button>
</ButtonStyled>
</div>
</div>
</div>
@@ -1470,38 +1749,94 @@ function copyId() {
</div>
</div>
<div class="flex w-40 items-center justify-center gap-2">
<ButtonStyled
color="brand"
:type="
getDetailDecision(flag.detail.id, flag.detail.status) === 'safe'
? undefined
: 'outlined'
"
<div class="detail-verdict-action-groups">
<div
class="detail-verdict-buttons"
role="group"
aria-label="Trace verdict actions"
>
<button
:disabled="updatingDetails.has(flag.detail.id)"
@click="updateDetailStatus(flag.detail.id, 'safe')"
v-tooltip="getDetailActionTooltip(flag.detail, 'safe', 'global')"
class="detail-verdict-button detail-verdict-button--safe"
:class="{
'detail-verdict-button--selected': isDetailActionSelected(
flag.detail,
'safe',
'global',
),
}"
aria-label="Global pass"
:disabled="
!canUpdateGlobalDetail(flag.detail) ||
updatingGlobalDetailKeys.has(flag.detail.key) ||
updatingDetails.has(flag.detail.id)
"
@click="updateGlobalDetailAction(flag.detail, 'safe')"
>
Pass
<CheckCheckIcon aria-hidden="true" />
</button>
</ButtonStyled>
<ButtonStyled
color="red"
:type="
getDetailDecision(flag.detail.id, flag.detail.status) === 'malware'
? undefined
: 'outlined'
"
>
<button
:disabled="updatingDetails.has(flag.detail.id)"
@click="updateDetailStatus(flag.detail.id, 'unsafe')"
v-tooltip="getDetailActionTooltip(flag.detail, 'safe', 'local')"
class="detail-verdict-button detail-verdict-button--safe"
:class="{
'detail-verdict-button--selected': isDetailActionSelected(
flag.detail,
'safe',
'local',
),
}"
aria-label="Local pass"
:disabled="
updatingDetails.has(flag.detail.id) ||
updatingGlobalDetailKeys.has(flag.detail.key)
"
@click="updateLocalDetailAction(flag.detail, 'safe')"
>
Fail
<CheckIcon aria-hidden="true" />
</button>
</ButtonStyled>
<button
v-tooltip="getDetailActionTooltip(flag.detail, 'malware', 'local')"
class="detail-verdict-button detail-verdict-button--unsafe"
:class="{
'detail-verdict-button--selected': isDetailActionSelected(
flag.detail,
'malware',
'local',
),
}"
aria-label="Local fail"
:disabled="
updatingDetails.has(flag.detail.id) ||
updatingGlobalDetailKeys.has(flag.detail.key)
"
@click="updateLocalDetailAction(flag.detail, 'malware')"
>
<BanIcon aria-hidden="true" />
</button>
<button
v-tooltip="getDetailActionTooltip(flag.detail, 'malware', 'global')"
class="detail-verdict-button detail-verdict-button--unsafe"
:class="{
'detail-verdict-button--selected': isDetailActionSelected(
flag.detail,
'malware',
'global',
),
}"
aria-label="Global fail"
:disabled="
!canUpdateGlobalDetail(flag.detail) ||
updatingGlobalDetailKeys.has(flag.detail.key) ||
updatingDetails.has(flag.detail.id)
"
@click="updateGlobalDetailAction(flag.detail, 'malware')"
>
<ShieldAlertIcon aria-hidden="true" />
</button>
</div>
</div>
</div>
<div
@@ -1609,4 +1944,90 @@ pre {
.fade-leave-to {
opacity: 0;
}
.detail-verdict-action-groups {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
margin-inline-end: 0.5rem;
}
.detail-verdict-buttons {
display: flex;
align-items: center;
overflow: hidden;
border: 1px solid var(--surface-5);
border-radius: var(--radius-md);
background: var(--surface-3);
}
.detail-verdict-button {
display: flex;
width: 2rem;
height: 2rem;
align-items: center;
justify-content: center;
border: 0;
border-left: 1px solid var(--surface-5);
background: transparent;
padding: 0;
cursor: pointer;
transition:
background-color 0.15s ease-in-out,
filter 0.15s ease-in-out;
}
.detail-verdict-button:first-child {
border-left: 0;
border-start-start-radius: calc(var(--radius-md) - 1px);
border-end-start-radius: calc(var(--radius-md) - 1px);
}
.detail-verdict-button:last-child {
border-start-end-radius: calc(var(--radius-md) - 1px);
border-end-end-radius: calc(var(--radius-md) - 1px);
}
.detail-verdict-button:hover,
.detail-verdict-button:focus-visible {
background: var(--surface-4);
}
.detail-verdict-button--selected {
background: var(--color-green-bg);
box-shadow: inset 0 0 0 1px var(--color-green);
}
.detail-verdict-button--selected:hover,
.detail-verdict-button--selected:focus-visible {
background: var(--color-green-bg);
}
.detail-verdict-button:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px var(--color-brand);
}
.detail-verdict-button--selected:focus-visible {
box-shadow: inset 0 0 0 2px var(--color-green);
}
.detail-verdict-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.detail-verdict-button svg {
width: 1rem;
height: 1rem;
}
.detail-verdict-button--safe {
color: var(--color-green);
}
.detail-verdict-button--unsafe {
color: var(--color-red);
}
</style>
@@ -54,15 +54,21 @@
class="message__icon backed-svg circle moderation-color"
:class="{
raised: raised,
'system-message-icon': ['tech_review_entered', 'tech_review_exit_file_deleted'].includes(
message.body.type,
),
'system-message-icon': [
'tech_review_entered',
'tech_review_exited',
'tech_review_exit_file_deleted',
].includes(message.body.type),
}"
>
<ScaleIcon />
</div>
<span
v-if="!['tech_review_entered', 'tech_review_exit_file_deleted'].includes(message.body.type)"
v-if="
!['tech_review_entered', 'tech_review_exited', 'tech_review_exit_file_deleted'].includes(
message.body.type,
)
"
class="message__author moderation-color"
>
Moderator
@@ -100,6 +106,9 @@
<span v-else-if="message.body.type === 'tech_review_entered'">
The project has entered the technical review queue.
</span>
<span v-else-if="message.body.type === 'tech_review_exited'">
The project has left the technical review queue as all pending traces have been resolved.
</span>
<span v-else-if="message.body.type === 'tech_review_exit_file_deleted'">
The project has left the technical review queue as all files pending review were deleted by
the user.
@@ -214,9 +223,12 @@ const timeSincePosted = ref(formatRelativeTime(props.message.created))
const isPrivateMessage = computed(() => {
return (
props.message.body.private ||
['tech_review', 'tech_review_entered', 'tech_review_exit_file_deleted'].includes(
props.message.body.type,
)
[
'tech_review',
'tech_review_entered',
'tech_review_exited',
'tech_review_exit_file_deleted',
].includes(props.message.body.type)
)
})
@@ -33,7 +33,15 @@
{{ pageStart }}-{{ pageEnd }} of {{ trace.local_trace_count }} local traces
</p>
</div>
<Badge :type="trace.verdict" />
<div class="flex shrink-0 flex-wrap items-center gap-2">
<Badge :type="trace.verdict" />
<ButtonStyled color="red">
<button :disabled="isRemoving" @click="removeGlobalTrace">
<TrashIcon aria-hidden="true" />
Remove
</button>
</ButtonStyled>
</div>
</div>
<div
@@ -63,13 +71,22 @@
<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 { ArrowLeftIcon, HashIcon, TrashIcon } from '@modrinth/assets'
import {
Badge,
ButtonStyled,
EmptyState,
injectModrinthClient,
injectNotificationManager,
Pagination,
} from '@modrinth/ui'
import GlobalDetailLocalTraceCard from '~/components/ui/moderation/GlobalDetailLocalTraceCard.vue'
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const route = useRoute()
const router = useRouter()
const detailKey = computed(() => {
const key = route.params.key
@@ -80,6 +97,7 @@ useHead({ title: () => `Global trace - ${detailKey.value} - Modrinth` })
const localTracePageSize = 20
const isLoading = ref(false)
const isRemoving = ref(false)
const loadError = ref(false)
const currentPage = ref(1)
const pageStartCursors = ref<(string | null)[]>([null])
@@ -140,6 +158,34 @@ async function switchPage(page: number) {
await loadPage(page)
}
async function removeGlobalTrace() {
if (isRemoving.value) return
isRemoving.value = true
try {
await client.labrinth.tech_review_internal.updateGlobalIssueDetails([
{ detail_key: detailKey.value, verdict: 'pending' },
])
addNotification({
type: 'success',
title: 'Global trace removed',
text: 'The global verdict for this trace key has been removed.',
})
await router.push('/moderation/global-traces')
} catch (error) {
console.error('Failed to remove global detail trace', error)
addNotification({
type: 'error',
title: 'Failed to remove global trace',
text: 'An error occurred while removing the global trace verdict.',
})
} finally {
isRemoving.value = false
}
}
watch(
detailKey,
() => {