From 3d31214715bf4db1e524add2a33e662eef82bb82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 30 Jun 2026 12:14:00 -0600 Subject: [PATCH 1/5] feat[backend](alerts): added echoes alerts --- .../modules/alerts/connectors/repository.go | 1 + backend/modules/alerts/connectors/usecase.go | 1 + backend/modules/alerts/handler/alerts.go | 34 +++++++++++++++++++ backend/modules/alerts/repository/alert_os.go | 17 ++++++++++ backend/modules/alerts/repository/osquery.go | 31 +++++++++++++++++ backend/modules/alerts/routes.go | 1 + backend/modules/alerts/usecase/alert.go | 23 +++++++++++++ 7 files changed, 108 insertions(+) diff --git a/backend/modules/alerts/connectors/repository.go b/backend/modules/alerts/connectors/repository.go index b02dba548..4aba4620f 100644 --- a/backend/modules/alerts/connectors/repository.go +++ b/backend/modules/alerts/connectors/repository.go @@ -19,6 +19,7 @@ type AlertRepository interface { CountOpenAlerts(ctx context.Context) (int64, error) CountByStatus(ctx context.Context, status int) (int64, error) SearchByIDs(ctx context.Context, alertIDs []string) ([]domain.UtmAlert, error) + ListEchoes(ctx context.Context, parentID string, from, size int, sortBy, sortOrder string) ([]domain.UtmAlert, int64, error) GetRawByID(ctx context.Context, alertID string) (json.RawMessage, error) RelatedLogRefs(ctx context.Context, steps []domain.CorrelationStep, anchorTS time.Time, maxSize int) (refs []domain.LogRef, truncated bool, err error) } diff --git a/backend/modules/alerts/connectors/usecase.go b/backend/modules/alerts/connectors/usecase.go index d51d1dc66..1fdbc85c5 100644 --- a/backend/modules/alerts/connectors/usecase.go +++ b/backend/modules/alerts/connectors/usecase.go @@ -25,6 +25,7 @@ type AlertUsecase interface { ConvertToIncident(ctx context.Context, userLogin string, req dto.ConvertToIncidentRequest) error CountOpenAlerts(ctx context.Context) (*dto.CountOpenAlertsResponse, error) RelatedLogs(ctx context.Context, alertID string) (*dto.RelatedLogsResponse, error) + ListEchoes(ctx context.Context, parentID string, page, size int, sortBy, sortOrder string) ([]domain.UtmAlert, int64, error) SetCorrelationResolver(r CorrelationResolver) } diff --git a/backend/modules/alerts/handler/alerts.go b/backend/modules/alerts/handler/alerts.go index 192b6f746..57dd4c1a5 100644 --- a/backend/modules/alerts/handler/alerts.go +++ b/backend/modules/alerts/handler/alerts.go @@ -185,3 +185,37 @@ func (h *AlertHandler) CountOpenAlerts(c *gin.Context) { // Java returns bare Long — match that contract. c.JSON(http.StatusOK, resp.Count) } + +// @Summary List echoes of an alert +// @Description Returns the child alerts (parentId == :id) of a parent alert, paginated and sorted. +// @Tags Alerts +// @Security BearerAuth +// @Produce json +// @Param id path string true "Parent alert id" +// @Param page query int false "Page number (1-based, default 1)" +// @Param size query int false "Page size (default 20, max 100)" +// @Param sortBy query string false "Sort field (default @timestamp)" +// @Param sortOrder query string false "Sort order: asc|desc (default desc)" +// @Success 200 {array} domain.UtmAlert +// @Header 200 {string} X-Total-Count "Total matching echoes" +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /utm-alerts/{id}/echoes [get] +func (h *AlertHandler) ListEchoes(c *gin.Context) { + parentID := c.Param("id") + if parentID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing alert id"}) + return + } + page := queryInt(c, "page", 1) + size := queryInt(c, "size", 20) + sortBy := c.Query("sortBy") + sortOrder := c.DefaultQuery("sortOrder", "desc") + + items, total, err := h.usecase.ListEchoes(c.Request.Context(), parentID, page, size, sortBy, sortOrder) + if err != nil { + writeAlertError(c, err) + return + } + writePagedArray(c, items, total) +} diff --git a/backend/modules/alerts/repository/alert_os.go b/backend/modules/alerts/repository/alert_os.go index 184288eca..c34f3b350 100644 --- a/backend/modules/alerts/repository/alert_os.go +++ b/backend/modules/alerts/repository/alert_os.go @@ -201,3 +201,20 @@ func (r *osAlertRepo) SearchByIDs(ctx context.Context, alertIDs []string) ([]dom return alerts, nil } +func (r *osAlertRepo) ListEchoes(ctx context.Context, parentID string, from, size int, sortBy, sortOrder string) ([]domain.UtmAlert, int64, error) { + query := termQuery("parentId.keyword", parentID) + raws, total, err := osSearchPage(ctx, alertIndex, query, from, size, sortBy, sortOrder) + if err != nil { + return nil, 0, err + } + alerts := make([]domain.UtmAlert, 0, len(raws)) + for _, raw := range raws { + var a domain.UtmAlert + if err := json.Unmarshal(raw, &a); err != nil { + continue + } + alerts = append(alerts, a) + } + return alerts, total, nil +} + diff --git a/backend/modules/alerts/repository/osquery.go b/backend/modules/alerts/repository/osquery.go index b0b8de104..b356a97f0 100644 --- a/backend/modules/alerts/repository/osquery.go +++ b/backend/modules/alerts/repository/osquery.go @@ -100,6 +100,37 @@ func osSearchSources(ctx context.Context, index string, query map[string]any, si return docs, nil } +// osSearchPage runs a paged + sorted search and returns the raw source docs +// alongside the matching total count. +func osSearchPage(ctx context.Context, index string, query map[string]any, from, size int, sortBy, sortOrder string) ([]json.RawMessage, int64, error) { + body := map[string]any{ + "from": from, + "size": size, + "track_total_hits": true, + } + if query != nil { + body["query"] = query + } + if sortBy != "" { + body["sort"] = []map[string]any{ + {sortBy: map[string]any{"order": sortOrder}}, + } + } + res, err := osdk.RawSearch(ctx, []string{index}, body) + if err != nil { + return nil, 0, err + } + docs := make([]json.RawMessage, 0, len(res.Hits.Hits)) + for _, h := range res.Hits.Hits { + b, err := json.Marshal(h.Source) + if err != nil { + return nil, 0, err + } + docs = append(docs, b) + } + return docs, res.Hits.Total.Value, nil +} + func osCount(ctx context.Context, index string, query map[string]any) (int64, error) { body := map[string]any{"size": 0, "track_total_hits": true} if query != nil { diff --git a/backend/modules/alerts/routes.go b/backend/modules/alerts/routes.go index 2a0a0c7c5..a9be57160 100644 --- a/backend/modules/alerts/routes.go +++ b/backend/modules/alerts/routes.go @@ -24,6 +24,7 @@ func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) { ag.GET("/count-open-alerts", read, h.CountOpenAlerts) ag.GET("/related-logs", read, h.RelatedLogs) + ag.GET("/:id/echoes", read, h.ListEchoes) tg := api.Group("/utm-alert-tags", userAuth) tg.POST("", write, th.Create) diff --git a/backend/modules/alerts/usecase/alert.go b/backend/modules/alerts/usecase/alert.go index 2dad8700a..3670b8140 100644 --- a/backend/modules/alerts/usecase/alert.go +++ b/backend/modules/alerts/usecase/alert.go @@ -160,6 +160,29 @@ func (u *alertUsecase) CountOpenAlerts(ctx context.Context) (*dto.CountOpenAlert return &dto.CountOpenAlertsResponse{Count: count}, nil } +func (u *alertUsecase) ListEchoes(ctx context.Context, parentID string, page, size int, sortBy, sortOrder string) ([]domain.UtmAlert, int64, error) { + if parentID == "" { + return nil, 0, domain.ErrMissingAlertID + } + if page < 1 { + page = 1 + } + if size < 1 { + size = 20 + } + if size > 100 { + size = 100 + } + if sortBy == "" { + sortBy = "@timestamp" + } + if sortOrder != "asc" && sortOrder != "desc" { + sortOrder = "desc" + } + from := (page - 1) * size + return u.repo.ListEchoes(ctx, parentID, from, size, sortBy, sortOrder) +} + // --------------------------------------------------------------------------- // Internal helpers for HistoryEntry construction // --------------------------------------------------------------------------- From d9070497ee19d60ca1b7362dd2d7c8176ced7c1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 30 Jun 2026 12:57:33 -0600 Subject: [PATCH 2/5] fix[frontend](alerts): added echoes indicator and timeline on alerts view --- .../alerts/components/alerts-table.tsx | 9 ++ .../alerts/components/echoes-chip.tsx | 49 +++++++++++ .../alerts/components/echoes-timeline.tsx | 84 +++++++++++++++++++ .../features/alerts/hooks/use-alert-echoes.ts | 59 +++++++++++++ .../src/features/alerts/lib/alert-meta.ts | 2 +- .../src/features/alerts/pages/AlertsPage.tsx | 42 ++++++---- .../alerts/services/alerts-http.service.ts | 8 ++ .../src/features/alerts/types/alert.types.ts | 1 + frontend/src/shared/i18n/locales/de.json | 10 ++- frontend/src/shared/i18n/locales/en.json | 10 ++- frontend/src/shared/i18n/locales/es.json | 10 ++- frontend/src/shared/i18n/locales/fr.json | 10 ++- frontend/src/shared/i18n/locales/it.json | 10 ++- frontend/src/shared/i18n/locales/pt.json | 10 ++- frontend/src/shared/i18n/locales/ru.json | 10 ++- 15 files changed, 302 insertions(+), 22 deletions(-) create mode 100644 frontend/src/features/alerts/components/echoes-chip.tsx create mode 100644 frontend/src/features/alerts/components/echoes-timeline.tsx create mode 100644 frontend/src/features/alerts/hooks/use-alert-echoes.ts diff --git a/frontend/src/features/alerts/components/alerts-table.tsx b/frontend/src/features/alerts/components/alerts-table.tsx index d10b79652..769d27d89 100644 --- a/frontend/src/features/alerts/components/alerts-table.tsx +++ b/frontend/src/features/alerts/components/alerts-table.tsx @@ -4,6 +4,7 @@ import { cn } from '@/shared/lib/utils' import { SEV_META, ST_META, TABLE_COLS, TS, absTime, flagEmoji, relativeTime, riskOf, sevKey, statusKey } from '../lib/alert-meta' import { isAiNote } from '../lib/ai-note' import type { Alert, AlertTag, Side } from '../types/alert.types' +import { EchoesChip } from './echoes-chip' import { TagChip } from './ui-primitives' export function AlertsTableHeader({ allChecked, onTogglePage }: { allChecked: boolean; onTogglePage: () => void }) { @@ -22,6 +23,7 @@ export function AlertsTableHeader({ allChecked, onTogglePage }: { allChecked: bo
{t('alerts.table.technique')}
{t('alerts.table.sourceAdversary')}
{t('alerts.table.risk')}
+
{t('alerts.table.echoes')}
{t('alerts.table.time')}
@@ -32,16 +34,20 @@ export function AlertRow({ alert: a, tagCatalog, checked, + expanded, onToggle, onOpen, onCreateRule, + onToggleEchoes, }: { alert: Alert tagCatalog: AlertTag[] checked: boolean + expanded: boolean onToggle: () => void onOpen: () => void onCreateRule: (alert: Alert) => void + onToggleEchoes: () => void }) { const { t } = useTranslation() const stm = ST_META[statusKey(a)] @@ -142,6 +148,9 @@ export function AlertRow({ {riskOf(a)} +
+ +
{relativeTime(a[TS])}
) diff --git a/frontend/src/features/alerts/components/echoes-chip.tsx b/frontend/src/features/alerts/components/echoes-chip.tsx new file mode 100644 index 000000000..7ece2506a --- /dev/null +++ b/frontend/src/features/alerts/components/echoes-chip.tsx @@ -0,0 +1,49 @@ +import { Radio } from 'lucide-react' +import { MouseEvent } from 'react' +import { useTranslation } from 'react-i18next' +import { cn } from '@/shared/lib/utils' + +/* Cyan-tinted chip showing the echo count for an alert row. Mirrors the + * visual shape (rounded ring + icon + label). When count is 0 the + * button is still rendered but visually disabled — clicks become no-ops. */ +export function EchoesChip({ + count, + expanded, + onClick, +}: { + count: number + expanded: boolean + onClick: () => void +}) { + const { t } = useTranslation() + const enabled = count > 0 + + const handleClick = (e: MouseEvent) => { + e.stopPropagation() + if (enabled) onClick() + } + + return ( + + ) +} diff --git a/frontend/src/features/alerts/components/echoes-timeline.tsx b/frontend/src/features/alerts/components/echoes-timeline.tsx new file mode 100644 index 000000000..6f9e32947 --- /dev/null +++ b/frontend/src/features/alerts/components/echoes-timeline.tsx @@ -0,0 +1,84 @@ +import { useMemo } from 'react' +import { AlertTriangle, Loader2, Radio } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { Pagination } from '@/shared/components/ui/pagination' +import { EChartsRenderer } from '@/features/dashboard/components/EChartsRenderer' +import { TS, absTime } from '../lib/alert-meta' +import { useAlertEchoes } from '../hooks/use-alert-echoes' + +interface TooltipParam { + data: [string, string, string] +} + +/* Inline panel rendered as a sibling of an expanded AlertRow. Styled as a + * message-style card: the cyan Radio icon (matching the table chip) sits at + * top-center, the scatter timeline sits below it. */ +export function EchoesTimeline({ parentId }: { parentId: string }) { + const { t } = useTranslation() + const { echoes, total, page, pageSize, setPage, setPageSize, loading, error } = + useAlertEchoes(parentId) + + const option = useMemo( + () => ({ + grid: { left: 12, right: 12, top: 8, bottom: 26, containLabel: false }, + xAxis: { type: 'time', axisLabel: { fontSize: 10 } }, + yAxis: { type: 'category', data: [''], show: false }, + tooltip: { + trigger: 'item', + formatter: (p: TooltipParam) => + `${p.data[2] || '—'}
${absTime(p.data[0])}`, + }, + series: [ + { + type: 'scatter', + symbolSize: 9, + itemStyle: { color: '#06b6d4', opacity: 0.85 }, + data: echoes.map((e) => [e[TS] ?? '', '', e.name ?? '—']), + }, + ], + }), + [echoes], + ) + + return ( +
+
+
+
+ +
+
+ {t('alerts.echoes.title', { count: total })} + {loading && } +
+
+ +
+ {error ? ( +
+ {t('alerts.echoes.loadError')} +
+ ) : total === 0 && !loading ? ( +
+ {t('alerts.echoes.empty')} +
+ ) : ( +
+ +
+ )} +
+ + +
+
+ ) +} diff --git a/frontend/src/features/alerts/hooks/use-alert-echoes.ts b/frontend/src/features/alerts/hooks/use-alert-echoes.ts new file mode 100644 index 000000000..9bb475dee --- /dev/null +++ b/frontend/src/features/alerts/hooks/use-alert-echoes.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useState } from 'react' +import { alertsHttpService as svc } from '../services/alerts-http.service' +import type { Alert } from '../types/alert.types' + +export interface UseAlertEchoesResult { + echoes: Alert[] + total: number + page: number + pageSize: number + setPage: (p: number) => void + setPageSize: (s: number) => void + loading: boolean + error: boolean +} + +/** Paginated child-echo list for a parent alert. Page is 0-based; the service + * expects 1-based, so we translate at the boundary. Skips the fetch when + * parentId is null (collapsed row). */ +export function useAlertEchoes(parentId: string | null): UseAlertEchoesResult { + const [echoes, setEchoes] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(0) + const [pageSize, setPageSize] = useState(20) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(false) + + const setPageSizeReset = useCallback((s: number) => { + setPageSize(s) + setPage(0) + }, []) + + useEffect(() => { + if (!parentId) return + let cancelled = false + setLoading(true) + setError(false) + svc + .echoes(parentId, page + 1, pageSize) + .then(({ data, total }) => { + if (cancelled) return + setEchoes(data ?? []) + setTotal(total) + }) + .catch(() => { + if (cancelled) return + setError(true) + setEchoes([]) + setTotal(0) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [parentId, page, pageSize]) + + return { echoes, total, page, pageSize, setPage, setPageSize: setPageSizeReset, loading, error } +} diff --git a/frontend/src/features/alerts/lib/alert-meta.ts b/frontend/src/features/alerts/lib/alert-meta.ts index 61fa84792..ed0b5fddd 100644 --- a/frontend/src/features/alerts/lib/alert-meta.ts +++ b/frontend/src/features/alerts/lib/alert-meta.ts @@ -75,7 +75,7 @@ export const PAGE_SIZE_DEFAULT = 20 export const SELECT_CLS = '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' -export const TABLE_COLS = '6px 24px 1fr 130px 150px 250px 40px 30px ' +export const TABLE_COLS = '6px 24px 1fr 130px 150px 250px 40px 60px 30px ' export function relativeTime(iso?: string) { if (!iso) return '—' diff --git a/frontend/src/features/alerts/pages/AlertsPage.tsx b/frontend/src/features/alerts/pages/AlertsPage.tsx index 450acbfe9..74468f7e1 100644 --- a/frontend/src/features/alerts/pages/AlertsPage.tsx +++ b/frontend/src/features/alerts/pages/AlertsPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AlertTriangle, Loader2 } from 'lucide-react' import { useLocation, useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' @@ -29,6 +29,7 @@ import { AlertsVolumeCard } from '../components/alerts-volume-card' import { AlertsBreakdownCard } from '../components/alerts-breakdown-card' import { AlertsBulkBar } from '../components/alerts-bulk-bar' import { AlertsTableHeader, AlertRow } from '../components/alerts-table' +import { EchoesTimeline } from '../components/echoes-timeline' import { AlertDrawer } from '../components/alert-drawer' import { AlertIncidentModal } from '../components/alert-incident-modal' import { Center } from '../components/ui-primitives' @@ -48,9 +49,18 @@ export function AlertsPage() { const [pageSize] = useState(50) const [selected, setSelected] = useState>(new Set()) + const [expandedEchoes, setExpandedEchoes] = useState>(new Set()) const [openAlert, setOpenAlert] = useState(null) const [incidentTargets, setIncidentTargets] = useState(null) + const toggleEchoes = (id: string) => + setExpandedEchoes((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + // SOC-AI chat navigation: seed the filters + time window the agent emitted. const location = useLocation() const navigate = useNavigate() @@ -239,19 +249,23 @@ export function AlertsPage() {
{t('alerts.list.empty')}
) : ( alerts.map((a) => ( - toggleSel(a.id)} - onOpen={() => setOpenAlert(a)} - onCreateRule={(alert) => - navigate('/threat-management/alerts/tagging-rules', { - state: { createWithConditions: alertToRuleConditions(alert) }, - }) - } - /> + + toggleSel(a.id)} + onOpen={() => setOpenAlert(a)} + onCreateRule={(alert) => + navigate('/threat-management/alerts/tagging-rules', { + state: { createWithConditions: alertToRuleConditions(alert) }, + }) + } + onToggleEchoes={() => toggleEchoes(a.id)} + /> + {expandedEchoes.has(a.id) && } + )) )} {alerts.length > 0 && ( diff --git a/frontend/src/features/alerts/services/alerts-http.service.ts b/frontend/src/features/alerts/services/alerts-http.service.ts index 561459b5d..c49347ac7 100644 --- a/frontend/src/features/alerts/services/alerts-http.service.ts +++ b/frontend/src/features/alerts/services/alerts-http.service.ts @@ -33,6 +33,7 @@ export const alertsHttpService = { top: String(MAX), sortBy: '@timestamp', sortOrder: 'desc', + includeChildren:'true' }) return api.postPaged(`/opensearch/search?${q.toString()}`, filters) }, @@ -91,6 +92,13 @@ export const alertsHttpService = { countOpen: () => api.get('/utm-alerts/count-open-alerts'), + // Paginated child "echoes" of a parent alert (the dedup children the main + // list hides). Backend sorts newest-first by default. + echoes: (parentId: string, page: number, size: number) => + api.getPaged( + `/utm-alerts/${encodeURIComponent(parentId)}/echoes?page=${page}&size=${size}`, + ), + // All logs the Event Processor correlated for this alert (reproduced server-side // without the engine's 10-hit cap) — for the "view all related logs" deep-link. relatedLogs: (alertId: string) => diff --git a/frontend/src/features/alerts/types/alert.types.ts b/frontend/src/features/alerts/types/alert.types.ts index af3c42c4d..57555a649 100644 --- a/frontend/src/features/alerts/types/alert.types.ts +++ b/frontend/src/features/alerts/types/alert.types.ts @@ -67,6 +67,7 @@ export interface Alert { assignee?: string history?: AlertHistoryEntry[] events?: AlertEventItem[] + echoes?: number } /** diff --git a/frontend/src/shared/i18n/locales/de.json b/frontend/src/shared/i18n/locales/de.json index e72b520e3..d56b7b043 100644 --- a/frontend/src/shared/i18n/locales/de.json +++ b/frontend/src/shared/i18n/locales/de.json @@ -3893,12 +3893,20 @@ "technique": "Technik", "sourceAdversary": "Quelle → Angreifer", "risk": "Risiko", + "echoes": "Echos", "time": "Zeit", "riskHint": "Risikobewertung (0–100), farblich nach Schweregrad" }, "row": { "createRuleFromAlert": "Tag aus dieser Warnung erstellen", - "assignedTo": "Zugewiesen an {{user}}" + "assignedTo": "Zugewiesen an {{user}}", + "echoesTooltip": "{{count}} Echos — klicken zum Erweitern", + "echoesEmpty": "Keine Echos vorhanden" + }, + "echoes": { + "title": "{{count}} Echos", + "empty": "Für diese Warnung wurden keine Echos erfasst.", + "loadError": "Echos konnten nicht geladen werden." }, "list": { "loading": "Warnungen werden geladen…", diff --git a/frontend/src/shared/i18n/locales/en.json b/frontend/src/shared/i18n/locales/en.json index e8534eda1..b9842c010 100644 --- a/frontend/src/shared/i18n/locales/en.json +++ b/frontend/src/shared/i18n/locales/en.json @@ -3954,12 +3954,20 @@ "technique": "Technique", "sourceAdversary": "Source → Adversary", "risk": "Risk", + "echoes": "Echoes", "time": "Time", "riskHint": "Risk score (0–100), colored by severity" }, "row": { "createRuleFromAlert": "Create a tag from this alert", - "assignedTo": "Assigned to {{user}}" + "assignedTo": "Assigned to {{user}}", + "echoesTooltip": "{{count}} echoes — click to expand", + "echoesEmpty": "No echoes recorded" + }, + "echoes": { + "title": "{{count}} echoes", + "empty": "No echoes recorded for this alert.", + "loadError": "Could not load echoes." }, "list": { "loading": "Loading alerts…", diff --git a/frontend/src/shared/i18n/locales/es.json b/frontend/src/shared/i18n/locales/es.json index fe32ff3fb..7cd9bd298 100644 --- a/frontend/src/shared/i18n/locales/es.json +++ b/frontend/src/shared/i18n/locales/es.json @@ -3981,12 +3981,20 @@ "technique": "Técnica", "sourceAdversary": "Origen → Adversario", "risk": "Riesgo", + "echoes": "Ecos", "time": "Hora", "riskHint": "Puntuación de riesgo (0–100), coloreada por severidad" }, "row": { "createRuleFromAlert": "Crear una etiqueta a partir de esta alerta", - "assignedTo": "Asignado a {{user}}" + "assignedTo": "Asignado a {{user}}", + "echoesTooltip": "{{count}} ecos — haz clic para expandir", + "echoesEmpty": "Sin ecos registrados" + }, + "echoes": { + "title": "{{count}} ecos", + "empty": "No hay ecos registrados para esta alerta.", + "loadError": "No se pudieron cargar los ecos." }, "list": { "loading": "Cargando alertas…", diff --git a/frontend/src/shared/i18n/locales/fr.json b/frontend/src/shared/i18n/locales/fr.json index 651d3f188..031bcb942 100644 --- a/frontend/src/shared/i18n/locales/fr.json +++ b/frontend/src/shared/i18n/locales/fr.json @@ -3893,12 +3893,20 @@ "technique": "Technique", "sourceAdversary": "Source → Adversaire", "risk": "Risque", + "echoes": "Échos", "time": "Heure", "riskHint": "Score de risque (0–100), coloré selon la gravité" }, "row": { "createRuleFromAlert": "Créer une étiquette à partir de cette alerte", - "assignedTo": "Attribuée à {{user}}" + "assignedTo": "Attribuée à {{user}}", + "echoesTooltip": "{{count}} échos — cliquez pour développer", + "echoesEmpty": "Aucun écho enregistré" + }, + "echoes": { + "title": "{{count}} échos", + "empty": "Aucun écho enregistré pour cette alerte.", + "loadError": "Impossible de charger les échos." }, "list": { "loading": "Chargement des alertes…", diff --git a/frontend/src/shared/i18n/locales/it.json b/frontend/src/shared/i18n/locales/it.json index 4dde33313..f2063366e 100644 --- a/frontend/src/shared/i18n/locales/it.json +++ b/frontend/src/shared/i18n/locales/it.json @@ -3893,12 +3893,20 @@ "technique": "Tecnica", "sourceAdversary": "Origine → Avversario", "risk": "Rischio", + "echoes": "Echi", "time": "Ora", "riskHint": "Punteggio di rischio (0–100), colorato in base alla gravità" }, "row": { "createRuleFromAlert": "Crea un'etichetta da questo avviso", - "assignedTo": "Assegnato a {{user}}" + "assignedTo": "Assegnato a {{user}}", + "echoesTooltip": "{{count}} echi — clicca per espandere", + "echoesEmpty": "Nessun eco registrato" + }, + "echoes": { + "title": "{{count}} echi", + "empty": "Nessun eco registrato per questo avviso.", + "loadError": "Impossibile caricare gli echi." }, "list": { "loading": "Caricamento avvisi…", diff --git a/frontend/src/shared/i18n/locales/pt.json b/frontend/src/shared/i18n/locales/pt.json index 6ecc5fb01..d8a83aa55 100644 --- a/frontend/src/shared/i18n/locales/pt.json +++ b/frontend/src/shared/i18n/locales/pt.json @@ -3981,12 +3981,20 @@ "technique": "Técnica", "sourceAdversary": "Origem → Adversário", "risk": "Risco", + "echoes": "Ecos", "time": "Hora", "riskHint": "Pontuação de risco (0–100), colorida por severidade" }, "row": { "createRuleFromAlert": "Criar uma tag a partir deste alerta", - "assignedTo": "Atribuído a {{user}}" + "assignedTo": "Atribuído a {{user}}", + "echoesTooltip": "{{count}} ecos — clique para expandir", + "echoesEmpty": "Sem ecos registrados" + }, + "echoes": { + "title": "{{count}} ecos", + "empty": "Nenhum eco registrado para este alerta.", + "loadError": "Não foi possível carregar os ecos." }, "list": { "loading": "Carregando alertas…", diff --git a/frontend/src/shared/i18n/locales/ru.json b/frontend/src/shared/i18n/locales/ru.json index 33e2ec797..02dbbdca9 100644 --- a/frontend/src/shared/i18n/locales/ru.json +++ b/frontend/src/shared/i18n/locales/ru.json @@ -3597,12 +3597,20 @@ "technique": "Техника", "sourceAdversary": "Источник → Противник", "risk": "Риск", + "echoes": "Эхо", "time": "Время", "riskHint": "Оценка риска (0–100), окрашена по серьёзности" }, "row": { "createRuleFromAlert": "Создать тег из этого оповещения", - "assignedTo": "Назначено: {{user}}" + "assignedTo": "Назначено: {{user}}", + "echoesTooltip": "{{count}} эхо — нажмите для развёртывания", + "echoesEmpty": "Эхо не зарегистрировано" + }, + "echoes": { + "title": "{{count}} эхо", + "empty": "Для этого оповещения нет зарегистрированных эхо.", + "loadError": "Не удалось загрузить эхо." }, "list": { "loading": "Загрузка оповещений…", From 5bddb05fa806805bc8bf83ab83fd95131c96301e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 30 Jun 2026 12:58:09 -0600 Subject: [PATCH 3/5] fix[frontend](alerts/tag_rules): added alerts redirection on tag rule cancel --- .../src/features/alerts/pages/TaggingRulesPage.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/alerts/pages/TaggingRulesPage.tsx b/frontend/src/features/alerts/pages/TaggingRulesPage.tsx index d9b5891c6..e5f1f37cb 100644 --- a/frontend/src/features/alerts/pages/TaggingRulesPage.tsx +++ b/frontend/src/features/alerts/pages/TaggingRulesPage.tsx @@ -217,7 +217,11 @@ export function TaggingRulesPage() { onClose={() => { setOpen(null) setOpenInEdit(false) - setRedirectAfter(null) + if(redirectAfter){ + const dest = redirectAfter + setRedirectAfter(null) + navigate(dest) + } }} onSubmit={(input, id) => submit(input, id ?? open.id)} onDelete={remove} @@ -234,7 +238,11 @@ export function TaggingRulesPage() { setCreating(false) setCreatingWith([]) setCreatingConditions([]) - setRedirectAfter(null) + if(redirectAfter){ + const dest = redirectAfter + setRedirectAfter(null) + navigate(dest) + } }} onSubmit={(input) => submit(input)} onCreateTag={createTag} From 645c1f019afd333feea6dc2cf011dc9e44dffc32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 30 Jun 2026 14:11:17 -0600 Subject: [PATCH 4/5] fix[frontend](alerts): unified alert status change button --- .../alerts/components/alert-drawer.tsx | 21 +--- .../alerts/components/alerts-table.tsx | 13 +- .../alerts/components/status-change-menu.tsx | 111 ++++++++++++++++++ .../components/status-observation-modal.tsx | 65 ++++++++++ .../src/features/alerts/pages/AlertsPage.tsx | 5 +- 5 files changed, 188 insertions(+), 27 deletions(-) create mode 100644 frontend/src/features/alerts/components/status-change-menu.tsx create mode 100644 frontend/src/features/alerts/components/status-observation-modal.tsx diff --git a/frontend/src/features/alerts/components/alert-drawer.tsx b/frontend/src/features/alerts/components/alert-drawer.tsx index 82bfa5c8d..346fa61f0 100644 --- a/frontend/src/features/alerts/components/alert-drawer.tsx +++ b/frontend/src/features/alerts/components/alert-drawer.tsx @@ -8,12 +8,13 @@ import { usersHttpService } from '@/features/team/services/team-http.service' import type { UserListItem } from '@/features/team/types/team.types' import { SEV_META, ST_META, TS, absTime, flagEmoji, relativeTime, riskOf, sevKey, statusKey } from '../lib/alert-meta' import { combineUserNote, isAiNote, parseAiNote, userNotePart } from '../lib/ai-note' -import { STATUS_BY_INT, STATUS_INT, type Alert, type AlertTag, type Side, type StatusKey } from '../types/alert.types' +import { STATUS_BY_INT, type Alert, type AlertTag, type Side } from '../types/alert.types' import { useRelatedLogs } from '../hooks/use-related-logs' import { Menu, Row, Section, TagChip } from './ui-primitives' import { AlertTagEditor } from './alert-tag-editor' import { AlertAiAssessment } from './alert-ai-assessment' import { AlertRelatedEvents } from './alert-related-events' +import { StatusChangeMenu } from './status-change-menu' type Tab = 'summary' | 'parties' | 'events' | 'history' @@ -99,23 +100,7 @@ export function AlertDrawer({ {/* Actions */}
- {t('alerts.drawer.setStatus')} }> - {(['open', 'in_review', 'completed'] as StatusKey[]).map((k) => ( - - ))} - - + void }) { @@ -39,6 +40,7 @@ export function AlertRow({ onOpen, onCreateRule, onToggleEchoes, + onStatus, }: { alert: Alert tagCatalog: AlertTag[] @@ -48,10 +50,9 @@ export function AlertRow({ onOpen: () => void onCreateRule: (alert: Alert) => void onToggleEchoes: () => void + onStatus: (status: number, observation: string, fp: boolean) => void }) { const { t } = useTranslation() - const stm = ST_META[statusKey(a)] - const stmLabel = t(`alerts.status.${statusKey(a)}`) const sk = sevKey(a) const sev = SEV_META[sk] return ( @@ -128,10 +129,8 @@ export function AlertRow({ )}
-
- - {stmLabel} - +
e.stopPropagation()}> +
{a.technique || '—'} diff --git a/frontend/src/features/alerts/components/status-change-menu.tsx b/frontend/src/features/alerts/components/status-change-menu.tsx new file mode 100644 index 000000000..878c65ff8 --- /dev/null +++ b/frontend/src/features/alerts/components/status-change-menu.tsx @@ -0,0 +1,111 @@ +import { useEffect, useRef, useState } from 'react' +import { ChevronDown } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { cn } from '@/shared/lib/utils' +import { ST_META } from '../lib/alert-meta' +import { STATUS_INT, type StatusKey } from '../types/alert.types' +import { StatusObservationModal } from './status-observation-modal' + +type Variant = 'pill' | 'action' + +type Pending = { status: number; fp: boolean; title: string } + +export function StatusChangeMenu({ + status, + onStatus, + variant, +}: { + status: StatusKey + onStatus: (status: number, observation: string, fp: boolean) => void + variant: Variant +}) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [pending, setPending] = useState(null) + const ref = useRef(null) + + useEffect(() => { + if (!open) return + const onDoc = (e: MouseEvent) => ref.current && !ref.current.contains(e.target as Node) && setOpen(false) + document.addEventListener('mousedown', onDoc) + return () => document.removeEventListener('mousedown', onDoc) + }, [open]) + + // Statuses that require a human-written observation before committing. + const pickStatus = (k: StatusKey) => { + setOpen(false) + if (k === 'completed') { + setPending({ status: STATUS_INT.completed, fp: false, title: t('alerts.status.completed') }) + return + } + onStatus(STATUS_INT[k], '', false) + } + + const pickFalsePositive = () => { + setOpen(false) + setPending({ status: STATUS_INT.completed, fp: true, title: t('alerts.drawer.completeFalsePositive') }) + } + + return ( +
+ + {open && ( +
e.stopPropagation()} + className="absolute left-0 top-full z-30 mt-1 w-48 rounded-md border border-border bg-popover py-1 shadow-lg" + > + {(['open', 'in_review', 'completed'] as StatusKey[]).map((k) => ( + + ))} + +
+ )} + {pending && ( + setPending(null)} + onConfirm={(observation) => { + onStatus(pending.status, observation, pending.fp) + setPending(null) + }} + /> + )} +
+ ) +} diff --git a/frontend/src/features/alerts/components/status-observation-modal.tsx b/frontend/src/features/alerts/components/status-observation-modal.tsx new file mode 100644 index 000000000..6c67dea5b --- /dev/null +++ b/frontend/src/features/alerts/components/status-observation-modal.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react' +import { X } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { Button } from '@/shared/components/ui/button' + +export function StatusObservationModal({ + title, + initialValue = '', + onCancel, + onConfirm, +}: { + title: string + initialValue?: string + onCancel: () => void + onConfirm: (observation: string) => void +}) { + const { t } = useTranslation() + const [observation, setObservation] = useState(initialValue) + const canSubmit = observation.trim().length > 0 + + return ( +
+
e.stopPropagation()} + > +
+

{title}

+ +
+ +
+ +