Skip to content

Commit d907049

Browse files
fix[frontend](alerts): added echoes indicator and timeline on alerts view
1 parent 3d31214 commit d907049

15 files changed

Lines changed: 302 additions & 22 deletions

File tree

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { cn } from '@/shared/lib/utils'
44
import { SEV_META, ST_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'
7+
import { EchoesChip } from './echoes-chip'
78
import { TagChip } from './ui-primitives'
89

910
export function AlertsTableHeader({ allChecked, onTogglePage }: { allChecked: boolean; onTogglePage: () => void }) {
@@ -22,6 +23,7 @@ export function AlertsTableHeader({ allChecked, onTogglePage }: { allChecked: bo
2223
<div>{t('alerts.table.technique')}</div>
2324
<div>{t('alerts.table.sourceAdversary')}</div>
2425
<div className="text-center" title={t('alerts.table.riskHint')}>{t('alerts.table.risk')}</div>
26+
<div className="text-center">{t('alerts.table.echoes')}</div>
2527
<div className="text-center">{t('alerts.table.time')}</div>
2628
<div />
2729
</div>
@@ -32,16 +34,20 @@ export function AlertRow({
3234
alert: a,
3335
tagCatalog,
3436
checked,
37+
expanded,
3538
onToggle,
3639
onOpen,
3740
onCreateRule,
41+
onToggleEchoes,
3842
}: {
3943
alert: Alert
4044
tagCatalog: AlertTag[]
4145
checked: boolean
46+
expanded: boolean
4247
onToggle: () => void
4348
onOpen: () => void
4449
onCreateRule: (alert: Alert) => void
50+
onToggleEchoes: () => void
4551
}) {
4652
const { t } = useTranslation()
4753
const stm = ST_META[statusKey(a)]
@@ -142,6 +148,9 @@ export function AlertRow({
142148
{riskOf(a)}
143149
</span>
144150
</div>
151+
<div className="flex justify-center">
152+
<EchoesChip count={a.echoes ?? 0} expanded={expanded} onClick={onToggleEchoes} />
153+
</div>
145154
<div className="text-center font-mono text-[11px] text-muted-foreground" title={absTime(a[TS])}>{relativeTime(a[TS])}</div>
146155
</div>
147156
)
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Radio } from 'lucide-react'
2+
import { MouseEvent } from 'react'
3+
import { useTranslation } from 'react-i18next'
4+
import { cn } from '@/shared/lib/utils'
5+
6+
/* Cyan-tinted chip showing the echo count for an alert row. Mirrors the
7+
* <TagChip> visual shape (rounded ring + icon + label). When count is 0 the
8+
* button is still rendered but visually disabled — clicks become no-ops. */
9+
export function EchoesChip({
10+
count,
11+
expanded,
12+
onClick,
13+
}: {
14+
count: number
15+
expanded: boolean
16+
onClick: () => void
17+
}) {
18+
const { t } = useTranslation()
19+
const enabled = count > 0
20+
21+
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
22+
e.stopPropagation()
23+
if (enabled) onClick()
24+
}
25+
26+
return (
27+
<button
28+
type="button"
29+
onClick={handleClick}
30+
disabled={!enabled}
31+
title={
32+
enabled
33+
? t('alerts.row.echoesTooltip', { count })
34+
: t('alerts.row.echoesEmpty')
35+
}
36+
aria-pressed={expanded}
37+
className={cn(
38+
'inline-flex items-center gap-1 whitespace-nowrap rounded-md px-1.5 py-0.5 text-[10px] font-medium leading-none ring-1 ring-inset transition-colors',
39+
enabled
40+
? 'bg-cyan-500/10 text-cyan-600 ring-cyan-500/30 hover:bg-cyan-500/20 dark:text-cyan-300'
41+
: 'cursor-not-allowed bg-muted/40 text-muted-foreground/60 ring-border/40',
42+
enabled && expanded && 'bg-cyan-500/25 ring-cyan-500/50',
43+
)}
44+
>
45+
<Radio size={10} className="shrink-0" />
46+
<span className="tabular-nums">{count}</span>
47+
</button>
48+
)
49+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { useMemo } from 'react'
2+
import { AlertTriangle, Loader2, Radio } from 'lucide-react'
3+
import { useTranslation } from 'react-i18next'
4+
import { Pagination } from '@/shared/components/ui/pagination'
5+
import { EChartsRenderer } from '@/features/dashboard/components/EChartsRenderer'
6+
import { TS, absTime } from '../lib/alert-meta'
7+
import { useAlertEchoes } from '../hooks/use-alert-echoes'
8+
9+
interface TooltipParam {
10+
data: [string, string, string]
11+
}
12+
13+
/* Inline panel rendered as a sibling of an expanded AlertRow. Styled as a
14+
* message-style card: the cyan Radio icon (matching the table chip) sits at
15+
* top-center, the scatter timeline sits below it. */
16+
export function EchoesTimeline({ parentId }: { parentId: string }) {
17+
const { t } = useTranslation()
18+
const { echoes, total, page, pageSize, setPage, setPageSize, loading, error } =
19+
useAlertEchoes(parentId)
20+
21+
const option = useMemo(
22+
() => ({
23+
grid: { left: 12, right: 12, top: 8, bottom: 26, containLabel: false },
24+
xAxis: { type: 'time', axisLabel: { fontSize: 10 } },
25+
yAxis: { type: 'category', data: [''], show: false },
26+
tooltip: {
27+
trigger: 'item',
28+
formatter: (p: TooltipParam) =>
29+
`<b>${p.data[2] || '—'}</b><br/>${absTime(p.data[0])}`,
30+
},
31+
series: [
32+
{
33+
type: 'scatter',
34+
symbolSize: 9,
35+
itemStyle: { color: '#06b6d4', opacity: 0.85 },
36+
data: echoes.map((e) => [e[TS] ?? '', '', e.name ?? '—']),
37+
},
38+
],
39+
}),
40+
[echoes],
41+
)
42+
43+
return (
44+
<div className="border-b border-border/50 bg-muted/20 px-6 py-4">
45+
<div className="mx-auto max-w-2xl rounded-lg border border-cyan-500/30 bg-cyan-500/5 px-5 py-4 shadow-sm">
46+
<div className="flex flex-col items-center gap-1">
47+
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-cyan-500/15 text-cyan-600 ring-1 ring-cyan-500/40 dark:text-cyan-300">
48+
<Radio size={16} />
49+
</div>
50+
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
51+
<span className="font-medium">{t('alerts.echoes.title', { count: total })}</span>
52+
{loading && <Loader2 size={12} className="animate-spin" />}
53+
</div>
54+
</div>
55+
56+
<div className="mt-3">
57+
{error ? (
58+
<div className="flex items-center justify-center gap-2 py-4 text-xs text-amber-600">
59+
<AlertTriangle size={14} /> {t('alerts.echoes.loadError')}
60+
</div>
61+
) : total === 0 && !loading ? (
62+
<div className="py-4 text-center text-xs text-muted-foreground">
63+
{t('alerts.echoes.empty')}
64+
</div>
65+
) : (
66+
<div className="h-24 w-full">
67+
<EChartsRenderer option={option} />
68+
</div>
69+
)}
70+
</div>
71+
72+
<Pagination
73+
page={page}
74+
pageSize={pageSize}
75+
total={total}
76+
loading={loading}
77+
onPageChange={setPage}
78+
onPageSizeChange={setPageSize}
79+
pageSizeOptions={[10, 20, 50, 100]}
80+
/>
81+
</div>
82+
</div>
83+
)
84+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { useCallback, useEffect, useState } from 'react'
2+
import { alertsHttpService as svc } from '../services/alerts-http.service'
3+
import type { Alert } from '../types/alert.types'
4+
5+
export interface UseAlertEchoesResult {
6+
echoes: Alert[]
7+
total: number
8+
page: number
9+
pageSize: number
10+
setPage: (p: number) => void
11+
setPageSize: (s: number) => void
12+
loading: boolean
13+
error: boolean
14+
}
15+
16+
/** Paginated child-echo list for a parent alert. Page is 0-based; the service
17+
* expects 1-based, so we translate at the boundary. Skips the fetch when
18+
* parentId is null (collapsed row). */
19+
export function useAlertEchoes(parentId: string | null): UseAlertEchoesResult {
20+
const [echoes, setEchoes] = useState<Alert[]>([])
21+
const [total, setTotal] = useState(0)
22+
const [page, setPage] = useState(0)
23+
const [pageSize, setPageSize] = useState(20)
24+
const [loading, setLoading] = useState(false)
25+
const [error, setError] = useState(false)
26+
27+
const setPageSizeReset = useCallback((s: number) => {
28+
setPageSize(s)
29+
setPage(0)
30+
}, [])
31+
32+
useEffect(() => {
33+
if (!parentId) return
34+
let cancelled = false
35+
setLoading(true)
36+
setError(false)
37+
svc
38+
.echoes(parentId, page + 1, pageSize)
39+
.then(({ data, total }) => {
40+
if (cancelled) return
41+
setEchoes(data ?? [])
42+
setTotal(total)
43+
})
44+
.catch(() => {
45+
if (cancelled) return
46+
setError(true)
47+
setEchoes([])
48+
setTotal(0)
49+
})
50+
.finally(() => {
51+
if (!cancelled) setLoading(false)
52+
})
53+
return () => {
54+
cancelled = true
55+
}
56+
}, [parentId, page, pageSize])
57+
58+
return { echoes, total, page, pageSize, setPage, setPageSize: setPageSizeReset, loading, error }
59+
}

frontend/src/features/alerts/lib/alert-meta.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export const PAGE_SIZE_DEFAULT = 20
7575
export const SELECT_CLS =
7676
'h-9 cursor-pointer rounded-md border border-input bg-background/40 px-2 text-sm transition-colors focus-visible:border-ring focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring'
7777

78-
export const TABLE_COLS = '6px 24px 1fr 130px 150px 250px 40px 30px '
78+
export const TABLE_COLS = '6px 24px 1fr 130px 150px 250px 40px 60px 30px '
7979

8080
export function relativeTime(iso?: string) {
8181
if (!iso) return '—'

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

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
1+
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
22
import { AlertTriangle, Loader2 } from 'lucide-react'
33
import { useLocation, useNavigate } from 'react-router-dom'
44
import { useTranslation } from 'react-i18next'
@@ -29,6 +29,7 @@ import { AlertsVolumeCard } from '../components/alerts-volume-card'
2929
import { AlertsBreakdownCard } from '../components/alerts-breakdown-card'
3030
import { AlertsBulkBar } from '../components/alerts-bulk-bar'
3131
import { AlertsTableHeader, AlertRow } from '../components/alerts-table'
32+
import { EchoesTimeline } from '../components/echoes-timeline'
3233
import { AlertDrawer } from '../components/alert-drawer'
3334
import { AlertIncidentModal } from '../components/alert-incident-modal'
3435
import { Center } from '../components/ui-primitives'
@@ -48,9 +49,18 @@ export function AlertsPage() {
4849
const [pageSize] = useState(50)
4950

5051
const [selected, setSelected] = useState<Set<string>>(new Set())
52+
const [expandedEchoes, setExpandedEchoes] = useState<Set<string>>(new Set())
5153
const [openAlert, setOpenAlert] = useState<Alert | null>(null)
5254
const [incidentTargets, setIncidentTargets] = useState<Alert[] | null>(null)
5355

56+
const toggleEchoes = (id: string) =>
57+
setExpandedEchoes((prev) => {
58+
const next = new Set(prev)
59+
if (next.has(id)) next.delete(id)
60+
else next.add(id)
61+
return next
62+
})
63+
5464
// SOC-AI chat navigation: seed the filters + time window the agent emitted.
5565
const location = useLocation()
5666
const navigate = useNavigate()
@@ -239,19 +249,23 @@ export function AlertsPage() {
239249
<div className="px-6 py-16 text-center text-sm text-muted-foreground">{t('alerts.list.empty')}</div>
240250
) : (
241251
alerts.map((a) => (
242-
<AlertRow
243-
key={a.id}
244-
alert={a}
245-
tagCatalog={tagCatalog}
246-
checked={selected.has(a.id)}
247-
onToggle={() => toggleSel(a.id)}
248-
onOpen={() => setOpenAlert(a)}
249-
onCreateRule={(alert) =>
250-
navigate('/threat-management/alerts/tagging-rules', {
251-
state: { createWithConditions: alertToRuleConditions(alert) },
252-
})
253-
}
254-
/>
252+
<Fragment key={a.id}>
253+
<AlertRow
254+
alert={a}
255+
tagCatalog={tagCatalog}
256+
checked={selected.has(a.id)}
257+
expanded={expandedEchoes.has(a.id)}
258+
onToggle={() => toggleSel(a.id)}
259+
onOpen={() => setOpenAlert(a)}
260+
onCreateRule={(alert) =>
261+
navigate('/threat-management/alerts/tagging-rules', {
262+
state: { createWithConditions: alertToRuleConditions(alert) },
263+
})
264+
}
265+
onToggleEchoes={() => toggleEchoes(a.id)}
266+
/>
267+
{expandedEchoes.has(a.id) && <EchoesTimeline parentId={a.id} />}
268+
</Fragment>
255269
))
256270
)}
257271
{alerts.length > 0 && (

frontend/src/features/alerts/services/alerts-http.service.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const alertsHttpService = {
3333
top: String(MAX),
3434
sortBy: '@timestamp',
3535
sortOrder: 'desc',
36+
includeChildren:'true'
3637
})
3738
return api.postPaged<Alert[]>(`/opensearch/search?${q.toString()}`, filters)
3839
},
@@ -91,6 +92,13 @@ export const alertsHttpService = {
9192

9293
countOpen: () => api.get<number>('/utm-alerts/count-open-alerts'),
9394

95+
// Paginated child "echoes" of a parent alert (the dedup children the main
96+
// list hides). Backend sorts newest-first by default.
97+
echoes: (parentId: string, page: number, size: number) =>
98+
api.getPaged<Alert[]>(
99+
`/utm-alerts/${encodeURIComponent(parentId)}/echoes?page=${page}&size=${size}`,
100+
),
101+
94102
// All logs the Event Processor correlated for this alert (reproduced server-side
95103
// without the engine's 10-hit cap) — for the "view all related logs" deep-link.
96104
relatedLogs: (alertId: string) =>

frontend/src/features/alerts/types/alert.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export interface Alert {
6767
assignee?: string
6868
history?: AlertHistoryEntry[]
6969
events?: AlertEventItem[]
70+
echoes?: number
7071
}
7172

7273
/**

frontend/src/shared/i18n/locales/de.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3893,12 +3893,20 @@
38933893
"technique": "Technik",
38943894
"sourceAdversary": "Quelle → Angreifer",
38953895
"risk": "Risiko",
3896+
"echoes": "Echos",
38963897
"time": "Zeit",
38973898
"riskHint": "Risikobewertung (0–100), farblich nach Schweregrad"
38983899
},
38993900
"row": {
39003901
"createRuleFromAlert": "Tag aus dieser Warnung erstellen",
3901-
"assignedTo": "Zugewiesen an {{user}}"
3902+
"assignedTo": "Zugewiesen an {{user}}",
3903+
"echoesTooltip": "{{count}} Echos — klicken zum Erweitern",
3904+
"echoesEmpty": "Keine Echos vorhanden"
3905+
},
3906+
"echoes": {
3907+
"title": "{{count}} Echos",
3908+
"empty": "Für diese Warnung wurden keine Echos erfasst.",
3909+
"loadError": "Echos konnten nicht geladen werden."
39023910
},
39033911
"list": {
39043912
"loading": "Warnungen werden geladen…",

frontend/src/shared/i18n/locales/en.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3954,12 +3954,20 @@
39543954
"technique": "Technique",
39553955
"sourceAdversary": "Source → Adversary",
39563956
"risk": "Risk",
3957+
"echoes": "Echoes",
39573958
"time": "Time",
39583959
"riskHint": "Risk score (0–100), colored by severity"
39593960
},
39603961
"row": {
39613962
"createRuleFromAlert": "Create a tag from this alert",
3962-
"assignedTo": "Assigned to {{user}}"
3963+
"assignedTo": "Assigned to {{user}}",
3964+
"echoesTooltip": "{{count}} echoes — click to expand",
3965+
"echoesEmpty": "No echoes recorded"
3966+
},
3967+
"echoes": {
3968+
"title": "{{count}} echoes",
3969+
"empty": "No echoes recorded for this alert.",
3970+
"loadError": "Could not load echoes."
39633971
},
39643972
"list": {
39653973
"loading": "Loading alerts…",

0 commit comments

Comments
 (0)