mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 09:04:55 +00:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user