Skip to content

Commit 9bcd574

Browse files
fix[frontend](alerts): make alerts table scrollable and refactor alerts components
1 parent 645c1f0 commit 9bcd574

37 files changed

Lines changed: 1514 additions & 1413 deletions
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { useEffect, useRef, useState } from 'react'
2+
import { ListFilter, Loader2, Search } from 'lucide-react'
3+
import { useTranslation } from 'react-i18next'
4+
import { cn } from '@/shared/lib/utils'
5+
import { Button } from '@/shared/components/ui/button'
6+
import { Input } from '@/shared/components/ui/input'
7+
import { alertsHttpService as svc } from '../services/alerts-http.service'
8+
import { FILTER_FIELDS, FILTER_OPS, SELECT_CLS, fieldKey } from '../lib/alert-meta'
9+
import type { CustomFilter } from '../types/alert.types'
10+
11+
export function AddFilterButton({ onAdd }: { onAdd: (f: CustomFilter) => void }) {
12+
const { t } = useTranslation()
13+
const [open, setOpen] = useState(false)
14+
const [field, setField] = useState(FILTER_FIELDS[0].field)
15+
const [operator, setOperator] = useState('IS')
16+
const [values, setValues] = useState<{ value: string; count: number }[]>([])
17+
const [loadingValues, setLoadingValues] = useState(false)
18+
const [vq, setVq] = useState('')
19+
const ref = useRef<HTMLDivElement>(null)
20+
21+
const op = FILTER_OPS.find((o) => o.id === operator)
22+
const needsValue = op?.needsValue ?? true
23+
24+
useEffect(() => {
25+
if (!open) return
26+
const onDoc = (e: MouseEvent) => ref.current && !ref.current.contains(e.target as Node) && setOpen(false)
27+
document.addEventListener('mousedown', onDoc)
28+
return () => document.removeEventListener('mousedown', onDoc)
29+
}, [open])
30+
31+
// Fetch the field's real existing values when the field changes.
32+
useEffect(() => {
33+
if (!open || !needsValue) return
34+
setLoadingValues(true)
35+
setValues([])
36+
svc
37+
.fieldValues(field)
38+
.then(setValues)
39+
.catch(() => setValues([]))
40+
.finally(() => setLoadingValues(false))
41+
}, [open, field, needsValue])
42+
43+
const add = (value: string) => {
44+
const fdef = FILTER_FIELDS.find((f) => f.field === field)!
45+
onAdd({ field, label: fdef.label, operator, value })
46+
setOpen(false)
47+
setVq('')
48+
}
49+
50+
const filtered = values.filter((v) => (vq ? String(v.value).toLowerCase().includes(vq.toLowerCase()) : true))
51+
52+
return (
53+
<div className="relative" ref={ref}>
54+
<button
55+
onClick={() => setOpen((v) => !v)}
56+
className="inline-flex items-center gap-1.5 rounded-full border border-dashed border-border px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
57+
>
58+
<ListFilter size={12} /> {t('alerts.filters.add')}
59+
</button>
60+
{open && (
61+
<div className="absolute left-0 top-full z-30 mt-1 w-80 rounded-md border border-border bg-popover p-3 shadow-lg">
62+
<div className="space-y-2">
63+
<select value={field} onChange={(e) => setField(e.target.value)} className={cn(SELECT_CLS, 'w-full')}>
64+
{FILTER_FIELDS.map((f) => (
65+
<option key={f.field} value={f.field}>
66+
{t(`alerts.fields.${fieldKey(f.field)}`)}
67+
</option>
68+
))}
69+
</select>
70+
<select value={operator} onChange={(e) => setOperator(e.target.value)} className={cn(SELECT_CLS, 'w-full')}>
71+
{FILTER_OPS.map((o) => (
72+
<option key={o.id} value={o.id}>
73+
{t(`alerts.ops.${o.id}`)}
74+
</option>
75+
))}
76+
</select>
77+
78+
{needsValue ? (
79+
<>
80+
<div className="relative">
81+
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
82+
<Input
83+
value={vq}
84+
onChange={(e) => setVq(e.target.value)}
85+
placeholder={t('alerts.filters.filterValues')}
86+
className="h-8 pl-8 text-xs"
87+
autoFocus
88+
/>
89+
</div>
90+
<div className="max-h-48 overflow-y-auto rounded-md border border-border">
91+
{loadingValues ? (
92+
<div className="flex items-center gap-1.5 px-3 py-3 text-xs text-muted-foreground">
93+
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t('alerts.filters.loadingValues')}
94+
</div>
95+
) : filtered.length === 0 ? (
96+
<div className="px-3 py-3 text-xs text-muted-foreground">{t('alerts.filters.noValues')}</div>
97+
) : (
98+
filtered.map((v) => (
99+
<button
100+
key={v.value}
101+
onClick={() => add(v.value)}
102+
className="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-xs hover:bg-muted"
103+
>
104+
<span className="truncate font-mono">{v.value || t('alerts.filters.empty')}</span>
105+
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">{v.count.toLocaleString()}</span>
106+
</button>
107+
))
108+
)}
109+
</div>
110+
<p className="text-[10px] text-muted-foreground">{t('alerts.filters.pickValue')}</p>
111+
</>
112+
) : (
113+
<div className="flex justify-end gap-2 pt-1">
114+
<Button variant="outline" size="sm" onClick={() => setOpen(false)}>
115+
{t('alerts.filters.cancel')}
116+
</Button>
117+
<Button size="sm" onClick={() => add('')}>
118+
{t('alerts.filters.addBtn')}
119+
</Button>
120+
</div>
121+
)}
122+
</div>
123+
</div>
124+
)}
125+
</div>
126+
)
127+
}

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

Lines changed: 11 additions & 195 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
1-
import { useEffect, useState } from 'react'
2-
import { ChevronDown, ExternalLink, Flame, UserPlus, X } from 'lucide-react'
1+
import { useState } from 'react'
2+
import { Flame, X } from 'lucide-react'
33
import { useTranslation } from 'react-i18next'
4-
import type { TFunction } from 'i18next'
54
import { cn } from '@/shared/lib/utils'
65
import { Button } from '@/shared/components/ui/button'
7-
import { usersHttpService } from '@/features/team/services/team-http.service'
8-
import type { UserListItem } from '@/features/team/types/team.types'
9-
import { SEV_META, ST_META, TS, absTime, flagEmoji, relativeTime, riskOf, sevKey, statusKey } from '../lib/alert-meta'
6+
import { SEV_META, ST_META, TS, absTime, riskOf, sevKey, statusKey } from '../lib/alert-meta'
107
import { combineUserNote, isAiNote, parseAiNote, userNotePart } from '../lib/ai-note'
11-
import { STATUS_BY_INT, type Alert, type AlertTag, type Side } from '../types/alert.types'
8+
import { type Alert, type AlertTag } from '../types/alert.types'
129
import { useRelatedLogs } from '../hooks/use-related-logs'
13-
import { Menu, Row, Section, TagChip } from './ui-primitives'
10+
import { Section } from './section'
11+
import { Row } from './row'
12+
import { TagChip } from './tag-chip'
1413
import { AlertTagEditor } from './alert-tag-editor'
1514
import { AlertAiAssessment } from './alert-ai-assessment'
1615
import { AlertRelatedEvents } from './alert-related-events'
1716
import { StatusChangeMenu } from './status-change-menu'
17+
import { TechniqueValue } from './technique-value'
18+
import { AssigneeMenu } from './assignee-menu'
19+
import { PartyCard } from './party-card'
20+
import { HistoryTab } from './history-tab'
1821

1922
type Tab = 'summary' | 'parties' | 'events' | 'history'
2023

@@ -257,190 +260,3 @@ export function AlertDrawer({
257260
</div>
258261
)
259262
}
260-
261-
// Renders the MITRE technique as a link to attack.mitre.org when it carries a
262-
// technique id (e.g. "T1110 - Brute Force" or "T1059.001 - PowerShell").
263-
function TechniqueValue({ technique }: { technique?: string }) {
264-
if (!technique) return <span className="font-mono"></span>
265-
const m = technique.match(/T\d{4}(?:\.\d{3})?/i)
266-
if (!m) return <span className="font-mono">{technique}</span>
267-
const id = m[0].toUpperCase()
268-
const path = id.replace('.', '/')
269-
return (
270-
<a
271-
href={`https://attack.mitre.org/techniques/${path}`}
272-
target="_blank"
273-
rel="noopener noreferrer"
274-
className="inline-flex items-center gap-1 font-mono text-primary hover:underline"
275-
>
276-
{technique}
277-
<ExternalLink size={11} className="shrink-0" />
278-
</a>
279-
)
280-
}
281-
282-
// Assign / reassign / clear an alert's owner. Loads the user list lazily on first
283-
// open so the dropdown isn't fetched for every alert row.
284-
function AssigneeMenu({ current, onAssign }: { current?: string; onAssign: (assignee: string) => void }) {
285-
const { t } = useTranslation()
286-
const [users, setUsers] = useState<UserListItem[] | null>(null)
287-
const [loaded, setLoaded] = useState(false)
288-
289-
useEffect(() => {
290-
if (!loaded) return
291-
let cancelled = false
292-
usersHttpService
293-
.list({ page_size: 200 })
294-
.then((r) => {
295-
if (!cancelled) setUsers(r.data ?? [])
296-
})
297-
.catch(() => {
298-
if (!cancelled) setUsers([])
299-
})
300-
return () => {
301-
cancelled = true
302-
}
303-
}, [loaded])
304-
305-
const label = (u: UserListItem) =>
306-
[u.first_name, u.last_name].filter(Boolean).join(' ') || u.login
307-
308-
return (
309-
<div onMouseEnter={() => setLoaded(true)} onFocusCapture={() => setLoaded(true)} onClickCapture={() => setLoaded(true)}>
310-
<Menu
311-
trigger={
312-
<>
313-
<UserPlus size={13} className="mr-1.5" />
314-
{current ? current : t('alerts.drawer.assign')}
315-
<ChevronDown size={12} className="ml-1" />
316-
</>
317-
}
318-
>
319-
{users == null ? (
320-
<div className="px-3 py-2 text-xs text-muted-foreground">{t('alerts.drawer.loadingUsers')}</div>
321-
) : (
322-
<>
323-
{users.map((u) => (
324-
<button
325-
key={u.id}
326-
onClick={() => onAssign(label(u))}
327-
className={cn(
328-
'block w-full px-3 py-1.5 text-left text-sm hover:bg-muted',
329-
current === label(u) && 'font-semibold text-primary'
330-
)}
331-
>
332-
{label(u)}
333-
</button>
334-
))}
335-
{current && (
336-
<button
337-
onClick={() => onAssign('')}
338-
className="block w-full border-t border-border px-3 py-1.5 text-left text-sm text-muted-foreground hover:bg-muted"
339-
>
340-
{t('alerts.drawer.unassign')}
341-
</button>
342-
)}
343-
</>
344-
)}
345-
</Menu>
346-
</div>
347-
)
348-
}
349-
350-
function PartyCard({ title, ep, accent }: { title: string; ep?: Side; accent?: boolean }) {
351-
const { t } = useTranslation()
352-
return (
353-
<div className={cn('rounded-lg border bg-card p-4', accent ? 'border-red-500/30' : 'border-border')}>
354-
<h4 className="mb-3 text-sm font-semibold">{title}</h4>
355-
{!ep ? (
356-
<p className="text-xs text-muted-foreground">{t('alerts.party.noData')}</p>
357-
) : (
358-
<dl className="grid grid-cols-[80px_1fr] gap-y-2 text-xs">
359-
{ep.ip && <Row k={t('alerts.party.ip')}><span className="font-mono">{ep.ip}</span></Row>}
360-
{ep.host && <Row k={t('alerts.party.host')}><span className="font-mono">{ep.host}</span></Row>}
361-
{ep.user && <Row k={t('alerts.party.user')}><span className="font-mono">{ep.user}</span></Row>}
362-
{ep.domain && <Row k={t('alerts.party.domain')}><span className="font-mono">{ep.domain}</span></Row>}
363-
{ep.geolocation?.country && (
364-
<Row k={t('alerts.party.country')}>
365-
{flagEmoji(ep.geolocation.countryCode)} {ep.geolocation.country}
366-
{ep.geolocation.city ? ` · ${ep.geolocation.city}` : ''}
367-
</Row>
368-
)}
369-
</dl>
370-
)}
371-
</div>
372-
)
373-
}
374-
375-
function HistoryTab({ alert: a }: { alert: Alert }) {
376-
const { t } = useTranslation()
377-
const history = a.history ?? []
378-
if (history.length === 0) return <p className="text-xs text-muted-foreground">{t('alerts.history.empty')}</p>
379-
return (
380-
<div className="space-y-2">
381-
{history
382-
.slice()
383-
.reverse()
384-
.map((h, i) => {
385-
const detail = historyDetail(h, t)
386-
return (
387-
<div key={i} className="rounded-md border border-border bg-card px-3 py-2 text-xs">
388-
<div className="flex items-center justify-between gap-2">
389-
<span className="font-medium">{actionLabel(h.action, t)}</span>
390-
<span className="font-mono text-[10px] text-muted-foreground">{relativeTime(h.timestamp)}</span>
391-
</div>
392-
{(detail || h.user) && (
393-
<div className="mt-0.5 text-muted-foreground">
394-
{detail}
395-
{h.user && (
396-
<span>
397-
{detail ? ' · ' : ''}
398-
{t('alerts.history.by', { user: h.user })}
399-
</span>
400-
)}
401-
</div>
402-
)}
403-
</div>
404-
)
405-
})}
406-
</div>
407-
)
408-
}
409-
410-
const ACTION_KEYS = ['UPDATE_STATUS', 'UPDATE_TAGS', 'UPDATE_NOTES', 'UPDATE_SOLUTION', 'MARK_AS_INCIDENT']
411-
function actionLabel(a: string | undefined, t: TFunction) {
412-
if (a && ACTION_KEYS.includes(a)) return t(`alerts.history.actions.${a}`)
413-
return (a ?? 'change').replace(/_/g, ' ').toLowerCase()
414-
}
415-
416-
// A clean one-line detail — never dumps the raw AI assessment or the newValue JSON.
417-
function historyDetail(h: { message?: string; newValue?: string }, t: TFunction): string {
418-
const msg = (h.message ?? '').trim()
419-
if (msg && !isAiNote(msg) && !msg.startsWith('{')) return msg
420-
try {
421-
const v = JSON.parse(h.newValue || '{}') as Record<string, unknown>
422-
const parts: string[] = []
423-
if (typeof v.status === 'number') {
424-
const stKey = STATUS_BY_INT[v.status]
425-
parts.push(
426-
t('alerts.history.detail.statusTo', { status: stKey ? t(`alerts.status.${stKey}`) : String(v.status) })
427-
)
428-
}
429-
if (v.tags != null)
430-
parts.push(
431-
t('alerts.history.detail.tags', {
432-
tags: Array.isArray(v.tags) ? (v.tags as string[]).join(', ') : String(v.tags),
433-
})
434-
)
435-
if (typeof v.statusObservation === 'string' && v.statusObservation)
436-
parts.push(
437-
isAiNote(v.statusObservation)
438-
? t('alerts.history.detail.aiAdded')
439-
: t('alerts.history.detail.observationAdded')
440-
)
441-
if (v.notes != null && !isAiNote(String(v.notes))) parts.push(t('alerts.history.detail.notesUpdated'))
442-
return parts.join(' · ')
443-
} catch {
444-
return ''
445-
}
446-
}

0 commit comments

Comments
 (0)