Skip to content

Commit 645c1f0

Browse files
fix[frontend](alerts): unified alert status change button
1 parent 5bddb05 commit 645c1f0

5 files changed

Lines changed: 188 additions & 27 deletions

File tree

frontend/src/features/alerts/components/alert-drawer.tsx

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ import { usersHttpService } from '@/features/team/services/team-http.service'
88
import type { UserListItem } from '@/features/team/types/team.types'
99
import { SEV_META, ST_META, TS, absTime, flagEmoji, relativeTime, riskOf, sevKey, statusKey } from '../lib/alert-meta'
1010
import { combineUserNote, isAiNote, parseAiNote, userNotePart } from '../lib/ai-note'
11-
import { STATUS_BY_INT, STATUS_INT, type Alert, type AlertTag, type Side, type StatusKey } from '../types/alert.types'
11+
import { STATUS_BY_INT, type Alert, type AlertTag, type Side } from '../types/alert.types'
1212
import { useRelatedLogs } from '../hooks/use-related-logs'
1313
import { Menu, Row, Section, TagChip } from './ui-primitives'
1414
import { AlertTagEditor } from './alert-tag-editor'
1515
import { AlertAiAssessment } from './alert-ai-assessment'
1616
import { AlertRelatedEvents } from './alert-related-events'
17+
import { StatusChangeMenu } from './status-change-menu'
1718

1819
type Tab = 'summary' | 'parties' | 'events' | 'history'
1920

@@ -99,23 +100,7 @@ export function AlertDrawer({
99100

100101
{/* Actions */}
101102
<div className="mt-4 flex flex-wrap items-center gap-2">
102-
<Menu trigger={<>{t('alerts.drawer.setStatus')} <ChevronDown size={12} /></>}>
103-
{(['open', 'in_review', 'completed'] as StatusKey[]).map((k) => (
104-
<button
105-
key={k}
106-
onClick={() => onStatus(STATUS_INT[k], '', false)}
107-
className="block w-full px-3 py-1.5 text-left text-sm hover:bg-muted"
108-
>
109-
{t(`alerts.status.${k}`)}
110-
</button>
111-
))}
112-
<button
113-
onClick={() => onStatus(STATUS_INT.completed, 'Marked as false positive', true)}
114-
className="block w-full border-t border-border px-3 py-1.5 text-left text-sm text-muted-foreground hover:bg-muted"
115-
>
116-
{t('alerts.drawer.completeFalsePositive')}
117-
</button>
118-
</Menu>
103+
<StatusChangeMenu status={statusKey(a)} variant="action" onStatus={onStatus} />
119104
<AlertTagEditor
120105
tags={tags}
121106
catalog={tagCatalog}

frontend/src/features/alerts/components/alerts-table.tsx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { ArrowRight, Sparkles, Tag, UserCheck } from 'lucide-react'
22
import { useTranslation } from 'react-i18next'
33
import { cn } from '@/shared/lib/utils'
4-
import { SEV_META, ST_META, TABLE_COLS, TS, absTime, flagEmoji, relativeTime, riskOf, sevKey, statusKey } from '../lib/alert-meta'
4+
import { SEV_META, TABLE_COLS, TS, absTime, flagEmoji, relativeTime, riskOf, sevKey, statusKey } from '../lib/alert-meta'
55
import { isAiNote } from '../lib/ai-note'
66
import type { Alert, AlertTag, Side } from '../types/alert.types'
77
import { EchoesChip } from './echoes-chip'
8+
import { StatusChangeMenu } from './status-change-menu'
89
import { TagChip } from './ui-primitives'
910

1011
export function AlertsTableHeader({ allChecked, onTogglePage }: { allChecked: boolean; onTogglePage: () => void }) {
@@ -39,6 +40,7 @@ export function AlertRow({
3940
onOpen,
4041
onCreateRule,
4142
onToggleEchoes,
43+
onStatus,
4244
}: {
4345
alert: Alert
4446
tagCatalog: AlertTag[]
@@ -48,10 +50,9 @@ export function AlertRow({
4850
onOpen: () => void
4951
onCreateRule: (alert: Alert) => void
5052
onToggleEchoes: () => void
53+
onStatus: (status: number, observation: string, fp: boolean) => void
5154
}) {
5255
const { t } = useTranslation()
53-
const stm = ST_META[statusKey(a)]
54-
const stmLabel = t(`alerts.status.${statusKey(a)}`)
5556
const sk = sevKey(a)
5657
const sev = SEV_META[sk]
5758
return (
@@ -128,10 +129,8 @@ export function AlertRow({
128129
)}
129130
</div>
130131
</div>
131-
<div>
132-
<span className={cn('inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset', stm.pill)}>
133-
{stmLabel}
134-
</span>
132+
<div onClick={(e) => e.stopPropagation()}>
133+
<StatusChangeMenu status={statusKey(a)} variant="pill" onStatus={onStatus} />
135134
</div>
136135
<div className="truncate font-mono text-[11px] text-muted-foreground" title={a.technique}>
137136
{a.technique || '—'}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { useEffect, useRef, useState } from 'react'
2+
import { ChevronDown } from 'lucide-react'
3+
import { useTranslation } from 'react-i18next'
4+
import { cn } from '@/shared/lib/utils'
5+
import { ST_META } from '../lib/alert-meta'
6+
import { STATUS_INT, type StatusKey } from '../types/alert.types'
7+
import { StatusObservationModal } from './status-observation-modal'
8+
9+
type Variant = 'pill' | 'action'
10+
11+
type Pending = { status: number; fp: boolean; title: string }
12+
13+
export function StatusChangeMenu({
14+
status,
15+
onStatus,
16+
variant,
17+
}: {
18+
status: StatusKey
19+
onStatus: (status: number, observation: string, fp: boolean) => void
20+
variant: Variant
21+
}) {
22+
const { t } = useTranslation()
23+
const [open, setOpen] = useState(false)
24+
const [pending, setPending] = useState<Pending | null>(null)
25+
const ref = useRef<HTMLDivElement>(null)
26+
27+
useEffect(() => {
28+
if (!open) return
29+
const onDoc = (e: MouseEvent) => ref.current && !ref.current.contains(e.target as Node) && setOpen(false)
30+
document.addEventListener('mousedown', onDoc)
31+
return () => document.removeEventListener('mousedown', onDoc)
32+
}, [open])
33+
34+
// Statuses that require a human-written observation before committing.
35+
const pickStatus = (k: StatusKey) => {
36+
setOpen(false)
37+
if (k === 'completed') {
38+
setPending({ status: STATUS_INT.completed, fp: false, title: t('alerts.status.completed') })
39+
return
40+
}
41+
onStatus(STATUS_INT[k], '', false)
42+
}
43+
44+
const pickFalsePositive = () => {
45+
setOpen(false)
46+
setPending({ status: STATUS_INT.completed, fp: true, title: t('alerts.drawer.completeFalsePositive') })
47+
}
48+
49+
return (
50+
<div className="relative inline-block" ref={ref}>
51+
<button
52+
type="button"
53+
onClick={(e) => {
54+
e.stopPropagation()
55+
setOpen((v) => !v)
56+
}}
57+
className={
58+
variant === 'pill'
59+
? cn(
60+
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset',
61+
ST_META[status].pill,
62+
)
63+
: 'inline-flex items-center gap-1 rounded-md border border-border bg-card px-2 py-1 hover:bg-muted'
64+
}
65+
>
66+
{variant === 'pill' ? (
67+
<>
68+
<span>{t(`alerts.status.${status}`)}</span>
69+
<ChevronDown size={10} />
70+
</>
71+
) : (
72+
<>
73+
{t('alerts.drawer.setStatus')} <ChevronDown size={12} />
74+
</>
75+
)}
76+
</button>
77+
{open && (
78+
<div
79+
onClick={(e) => e.stopPropagation()}
80+
className="absolute left-0 top-full z-30 mt-1 w-48 rounded-md border border-border bg-popover py-1 shadow-lg"
81+
>
82+
{(['open', 'in_review', 'completed'] as StatusKey[]).map((k) => (
83+
<button
84+
key={k}
85+
onClick={() => pickStatus(k)}
86+
className="block w-full px-3 py-1.5 text-left text-sm hover:bg-muted"
87+
>
88+
{t(`alerts.status.${k}`)}
89+
</button>
90+
))}
91+
<button
92+
onClick={pickFalsePositive}
93+
className="block w-full border-t border-border px-3 py-1.5 text-left text-sm text-muted-foreground hover:bg-muted"
94+
>
95+
{t('alerts.drawer.completeFalsePositive')}
96+
</button>
97+
</div>
98+
)}
99+
{pending && (
100+
<StatusObservationModal
101+
title={pending.title}
102+
onCancel={() => setPending(null)}
103+
onConfirm={(observation) => {
104+
onStatus(pending.status, observation, pending.fp)
105+
setPending(null)
106+
}}
107+
/>
108+
)}
109+
</div>
110+
)
111+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { useState } from 'react'
2+
import { X } from 'lucide-react'
3+
import { useTranslation } from 'react-i18next'
4+
import { Button } from '@/shared/components/ui/button'
5+
6+
export function StatusObservationModal({
7+
title,
8+
initialValue = '',
9+
onCancel,
10+
onConfirm,
11+
}: {
12+
title: string
13+
initialValue?: string
14+
onCancel: () => void
15+
onConfirm: (observation: string) => void
16+
}) {
17+
const { t } = useTranslation()
18+
const [observation, setObservation] = useState(initialValue)
19+
const canSubmit = observation.trim().length > 0
20+
21+
return (
22+
<div
23+
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
24+
onClick={onCancel}
25+
>
26+
<div
27+
className="flex w-full max-w-md flex-col overflow-hidden rounded-xl border border-border bg-card shadow-xl"
28+
onClick={(e) => e.stopPropagation()}
29+
>
30+
<header className="flex items-center justify-between gap-4 border-b border-border px-5 py-4">
31+
<h2 className="text-base font-semibold">{title}</h2>
32+
<button
33+
onClick={onCancel}
34+
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
35+
>
36+
<X size={16} />
37+
</button>
38+
</header>
39+
40+
<div className="px-5 py-4">
41+
<label className="mb-1 block text-xs font-medium text-foreground/80">
42+
{t('alerts.drawer.observationLabel')}
43+
</label>
44+
<textarea
45+
value={observation}
46+
onChange={(e) => setObservation(e.target.value)}
47+
rows={4}
48+
autoFocus
49+
placeholder={t('alerts.drawer.observationPlaceholder')}
50+
className="w-full rounded-md border border-input bg-background/40 p-2 text-xs focus-visible:border-ring focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
51+
/>
52+
</div>
53+
54+
<footer className="flex items-center justify-end gap-2 border-t border-border bg-muted/20 px-5 py-3">
55+
<Button variant="outline" size="sm" onClick={onCancel}>
56+
{t('alerts.drawer.cancel')}
57+
</Button>
58+
<Button size="sm" disabled={!canSubmit} onClick={() => onConfirm(observation.trim())}>
59+
{t('alerts.drawer.continue')}
60+
</Button>
61+
</footer>
62+
</div>
63+
</div>
64+
)
65+
}

frontend/src/features/alerts/pages/AlertsPage.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,8 +248,8 @@ export function AlertsPage() {
248248
) : alerts.length === 0 ? (
249249
<div className="px-6 py-16 text-center text-sm text-muted-foreground">{t('alerts.list.empty')}</div>
250250
) : (
251-
alerts.map((a) => (
252-
<Fragment key={a.id}>
251+
alerts.map((a,index) => (
252+
<Fragment key={`${a.id}-${index}`}>
253253
<AlertRow
254254
alert={a}
255255
tagCatalog={tagCatalog}
@@ -263,6 +263,7 @@ export function AlertsPage() {
263263
})
264264
}
265265
onToggleEchoes={() => toggleEchoes(a.id)}
266+
onStatus={(s, obs, fp) => void applyStatus([a.id], s, obs, fp)}
266267
/>
267268
{expandedEchoes.has(a.id) && <EchoesTimeline parentId={a.id} />}
268269
</Fragment>

0 commit comments

Comments
 (0)