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 // --------------------------------------------------------------------------- diff --git a/frontend/src/features/alerts/components/add-filter-button.tsx b/frontend/src/features/alerts/components/add-filter-button.tsx new file mode 100644 index 000000000..c5a8893c5 --- /dev/null +++ b/frontend/src/features/alerts/components/add-filter-button.tsx @@ -0,0 +1,127 @@ +import { useEffect, useRef, useState } from 'react' +import { ListFilter, Loader2, Search } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/components/ui/button' +import { Input } from '@/shared/components/ui/input' +import { alertsHttpService as svc } from '../services/alerts-http.service' +import { FILTER_FIELDS, FILTER_OPS, SELECT_CLS, fieldKey } from '../lib/alert-meta' +import type { CustomFilter } from '../types/alert.types' + +export function AddFilterButton({ onAdd }: { onAdd: (f: CustomFilter) => void }) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [field, setField] = useState(FILTER_FIELDS[0].field) + const [operator, setOperator] = useState('IS') + const [values, setValues] = useState<{ value: string; count: number }[]>([]) + const [loadingValues, setLoadingValues] = useState(false) + const [vq, setVq] = useState('') + const ref = useRef(null) + + const op = FILTER_OPS.find((o) => o.id === operator) + const needsValue = op?.needsValue ?? true + + 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]) + + // Fetch the field's real existing values when the field changes. + useEffect(() => { + if (!open || !needsValue) return + setLoadingValues(true) + setValues([]) + svc + .fieldValues(field) + .then(setValues) + .catch(() => setValues([])) + .finally(() => setLoadingValues(false)) + }, [open, field, needsValue]) + + const add = (value: string) => { + const fdef = FILTER_FIELDS.find((f) => f.field === field)! + onAdd({ field, label: fdef.label, operator, value }) + setOpen(false) + setVq('') + } + + const filtered = values.filter((v) => (vq ? String(v.value).toLowerCase().includes(vq.toLowerCase()) : true)) + + return ( +
+ + {open && ( +
+
+ + + + {needsValue ? ( + <> +
+ + setVq(e.target.value)} + placeholder={t('alerts.filters.filterValues')} + className="h-8 pl-8 text-xs" + autoFocus + /> +
+
+ {loadingValues ? ( +
+ {t('alerts.filters.loadingValues')} +
+ ) : filtered.length === 0 ? ( +
{t('alerts.filters.noValues')}
+ ) : ( + filtered.map((v) => ( + + )) + )} +
+

{t('alerts.filters.pickValue')}

+ + ) : ( +
+ + +
+ )} +
+
+ )} +
+ ) +} diff --git a/frontend/src/features/alerts/components/alert-drawer.tsx b/frontend/src/features/alerts/components/alert-drawer.tsx index 82bfa5c8d..5fdf4200a 100644 --- a/frontend/src/features/alerts/components/alert-drawer.tsx +++ b/frontend/src/features/alerts/components/alert-drawer.tsx @@ -1,19 +1,23 @@ -import { useEffect, useState } from 'react' -import { ChevronDown, ExternalLink, Flame, UserPlus, X } from 'lucide-react' +import { useState } from 'react' +import { Flame, X } from 'lucide-react' import { useTranslation } from 'react-i18next' -import type { TFunction } from 'i18next' import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/components/ui/button' -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 { SEV_META, ST_META, TS, absTime, 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 { type Alert, type AlertTag } from '../types/alert.types' import { useRelatedLogs } from '../hooks/use-related-logs' -import { Menu, Row, Section, TagChip } from './ui-primitives' +import { Section } from './section' +import { Row } from './row' +import { TagChip } from './tag-chip' import { AlertTagEditor } from './alert-tag-editor' import { AlertAiAssessment } from './alert-ai-assessment' import { AlertRelatedEvents } from './alert-related-events' +import { StatusChangeMenu } from './status-change-menu' +import { TechniqueValue } from './technique-value' +import { AssigneeMenu } from './assignee-menu' +import { PartyCard } from './party-card' +import { HistoryTab } from './history-tab' type Tab = 'summary' | 'parties' | 'events' | 'history' @@ -99,23 +103,7 @@ export function AlertDrawer({ {/* Actions */}
- {t('alerts.drawer.setStatus')} }> - {(['open', 'in_review', 'completed'] as StatusKey[]).map((k) => ( - - ))} - - + ) } - -// Renders the MITRE technique as a link to attack.mitre.org when it carries a -// technique id (e.g. "T1110 - Brute Force" or "T1059.001 - PowerShell"). -function TechniqueValue({ technique }: { technique?: string }) { - if (!technique) return - const m = technique.match(/T\d{4}(?:\.\d{3})?/i) - if (!m) return {technique} - const id = m[0].toUpperCase() - const path = id.replace('.', '/') - return ( - - {technique} - - - ) -} - -// Assign / reassign / clear an alert's owner. Loads the user list lazily on first -// open so the dropdown isn't fetched for every alert row. -function AssigneeMenu({ current, onAssign }: { current?: string; onAssign: (assignee: string) => void }) { - const { t } = useTranslation() - const [users, setUsers] = useState(null) - const [loaded, setLoaded] = useState(false) - - useEffect(() => { - if (!loaded) return - let cancelled = false - usersHttpService - .list({ page_size: 200 }) - .then((r) => { - if (!cancelled) setUsers(r.data ?? []) - }) - .catch(() => { - if (!cancelled) setUsers([]) - }) - return () => { - cancelled = true - } - }, [loaded]) - - const label = (u: UserListItem) => - [u.first_name, u.last_name].filter(Boolean).join(' ') || u.login - - return ( -
setLoaded(true)} onFocusCapture={() => setLoaded(true)} onClickCapture={() => setLoaded(true)}> - - - {current ? current : t('alerts.drawer.assign')} - - - } - > - {users == null ? ( -
{t('alerts.drawer.loadingUsers')}
- ) : ( - <> - {users.map((u) => ( - - ))} - {current && ( - - )} - - )} -
-
- ) -} - -function PartyCard({ title, ep, accent }: { title: string; ep?: Side; accent?: boolean }) { - const { t } = useTranslation() - return ( -
-

{title}

- {!ep ? ( -

{t('alerts.party.noData')}

- ) : ( -
- {ep.ip && {ep.ip}} - {ep.host && {ep.host}} - {ep.user && {ep.user}} - {ep.domain && {ep.domain}} - {ep.geolocation?.country && ( - - {flagEmoji(ep.geolocation.countryCode)} {ep.geolocation.country} - {ep.geolocation.city ? ` · ${ep.geolocation.city}` : ''} - - )} -
- )} -
- ) -} - -function HistoryTab({ alert: a }: { alert: Alert }) { - const { t } = useTranslation() - const history = a.history ?? [] - if (history.length === 0) return

{t('alerts.history.empty')}

- return ( -
- {history - .slice() - .reverse() - .map((h, i) => { - const detail = historyDetail(h, t) - return ( -
-
- {actionLabel(h.action, t)} - {relativeTime(h.timestamp)} -
- {(detail || h.user) && ( -
- {detail} - {h.user && ( - - {detail ? ' · ' : ''} - {t('alerts.history.by', { user: h.user })} - - )} -
- )} -
- ) - })} -
- ) -} - -const ACTION_KEYS = ['UPDATE_STATUS', 'UPDATE_TAGS', 'UPDATE_NOTES', 'UPDATE_SOLUTION', 'MARK_AS_INCIDENT'] -function actionLabel(a: string | undefined, t: TFunction) { - if (a && ACTION_KEYS.includes(a)) return t(`alerts.history.actions.${a}`) - return (a ?? 'change').replace(/_/g, ' ').toLowerCase() -} - -// A clean one-line detail — never dumps the raw AI assessment or the newValue JSON. -function historyDetail(h: { message?: string; newValue?: string }, t: TFunction): string { - const msg = (h.message ?? '').trim() - if (msg && !isAiNote(msg) && !msg.startsWith('{')) return msg - try { - const v = JSON.parse(h.newValue || '{}') as Record - const parts: string[] = [] - if (typeof v.status === 'number') { - const stKey = STATUS_BY_INT[v.status] - parts.push( - t('alerts.history.detail.statusTo', { status: stKey ? t(`alerts.status.${stKey}`) : String(v.status) }) - ) - } - if (v.tags != null) - parts.push( - t('alerts.history.detail.tags', { - tags: Array.isArray(v.tags) ? (v.tags as string[]).join(', ') : String(v.tags), - }) - ) - if (typeof v.statusObservation === 'string' && v.statusObservation) - parts.push( - isAiNote(v.statusObservation) - ? t('alerts.history.detail.aiAdded') - : t('alerts.history.detail.observationAdded') - ) - if (v.notes != null && !isAiNote(String(v.notes))) parts.push(t('alerts.history.detail.notesUpdated')) - return parts.join(' · ') - } catch { - return '' - } -} diff --git a/frontend/src/features/alerts/components/alert-row.tsx b/frontend/src/features/alerts/components/alert-row.tsx new file mode 100644 index 000000000..4bbbbda68 --- /dev/null +++ b/frontend/src/features/alerts/components/alert-row.tsx @@ -0,0 +1,141 @@ +import { Sparkles, Tag, UserCheck } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { cn } from '@/shared/lib/utils' +import { SEV_META, TS, absTime, relativeTime, riskOf, sevKey, statusKey } from '../lib/alert-meta' +import { isAiNote } from '../lib/ai-note' +import type { Alert, AlertTag } from '../types/alert.types' +import { EchoesChip } from './echoes-chip' +import { StatusChangeMenu } from './status-change-menu' +import { TagChip } from './tag-chip' +import { FlowCell } from './flow-cell' + +const TD = 'whitespace-nowrap px-3 py-2.5 align-middle' + +export function AlertRow({ + alert: a, + tagCatalog, + checked, + expanded, + onToggle, + onOpen, + onCreateRule, + onToggleEchoes, + onStatus, +}: { + alert: Alert + tagCatalog: AlertTag[] + checked: boolean + expanded: boolean + onToggle: () => void + onOpen: () => void + onCreateRule: (alert: Alert) => void + onToggleEchoes: () => void + onStatus: (status: number, observation: string, fp: boolean) => void +}) { + const { t } = useTranslation() + const sk = sevKey(a) + const sev = SEV_META[sk] + return ( + + {/* Severity accent — colored left edge so the row's risk reads at a glance. */} + + + + + + + + + + +
+ {a.name || '—'} + {(isAiNote(a.notes) || isAiNote(a.statusObservation)) && ( + + )} + {a.isIncident && ( + + {t('alerts.badge.incident')} + + )} +
+
+ + {a.category} + {a.dataSource && ` · ${a.dataSource}`} + + {(a.tags ?? []).slice(0, 2).map((tag) => ( + + ))} + {(a.tags ?? []).length > 2 && ( + + +{(a.tags ?? []).length - 2} + + )} + {a.assignee && ( + + + {a.assignee} + + )} +
+ + e.stopPropagation()}> + + + + {a.technique || '—'} + + + + + + + {riskOf(a)} + + + + + + + {relativeTime(a[TS])} + + + ) +} diff --git a/frontend/src/features/alerts/components/alerts-bulk-bar.tsx b/frontend/src/features/alerts/components/alerts-bulk-bar.tsx index b64147902..40b3ca5db 100644 --- a/frontend/src/features/alerts/components/alerts-bulk-bar.tsx +++ b/frontend/src/features/alerts/components/alerts-bulk-bar.tsx @@ -1,7 +1,7 @@ import { ChevronDown, Flame, Tag as TagIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { STATUS_INT, type AlertTag, type StatusKey } from '../types/alert.types' -import { Menu } from './ui-primitives' +import { Menu } from './menu' export function AlertsBulkBar({ count, diff --git a/frontend/src/features/alerts/components/alerts-filter-bar.tsx b/frontend/src/features/alerts/components/alerts-filter-bar.tsx index 04c731860..26e27a111 100644 --- a/frontend/src/features/alerts/components/alerts-filter-bar.tsx +++ b/frontend/src/features/alerts/components/alerts-filter-bar.tsx @@ -1,12 +1,8 @@ -import { useEffect, useRef, useState } from 'react' -import { ListFilter, Loader2, Search, X } from 'lucide-react' +import { X } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { cn } from '@/shared/lib/utils' -import { Button } from '@/shared/components/ui/button' -import { Input } from '@/shared/components/ui/input' -import { alertsHttpService as svc } from '../services/alerts-http.service' -import { FILTER_FIELDS, FILTER_OPS, SELECT_CLS, fieldKey } from '../lib/alert-meta' +import { FILTER_OPS, fieldKey } from '../lib/alert-meta' import type { CustomFilter } from '../types/alert.types' +import { AddFilterButton } from './add-filter-button' export function AlertsFilterBar({ filters, @@ -53,121 +49,3 @@ export function AlertsFilterBar({
) } - -function AddFilterButton({ onAdd }: { onAdd: (f: CustomFilter) => void }) { - const { t } = useTranslation() - const [open, setOpen] = useState(false) - const [field, setField] = useState(FILTER_FIELDS[0].field) - const [operator, setOperator] = useState('IS') - const [values, setValues] = useState<{ value: string; count: number }[]>([]) - const [loadingValues, setLoadingValues] = useState(false) - const [vq, setVq] = useState('') - const ref = useRef(null) - - const op = FILTER_OPS.find((o) => o.id === operator) - const needsValue = op?.needsValue ?? true - - 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]) - - // Fetch the field's real existing values when the field changes. - useEffect(() => { - if (!open || !needsValue) return - setLoadingValues(true) - setValues([]) - svc - .fieldValues(field) - .then(setValues) - .catch(() => setValues([])) - .finally(() => setLoadingValues(false)) - }, [open, field, needsValue]) - - const add = (value: string) => { - const fdef = FILTER_FIELDS.find((f) => f.field === field)! - onAdd({ field, label: fdef.label, operator, value }) - setOpen(false) - setVq('') - } - - const filtered = values.filter((v) => (vq ? String(v.value).toLowerCase().includes(vq.toLowerCase()) : true)) - - return ( -
- - {open && ( -
-
- - - - {needsValue ? ( - <> -
- - setVq(e.target.value)} - placeholder={t('alerts.filters.filterValues')} - className="h-8 pl-8 text-xs" - autoFocus - /> -
-
- {loadingValues ? ( -
- {t('alerts.filters.loadingValues')} -
- ) : filtered.length === 0 ? ( -
{t('alerts.filters.noValues')}
- ) : ( - filtered.map((v) => ( - - )) - )} -
-

{t('alerts.filters.pickValue')}

- - ) : ( -
- - -
- )} -
-
- )} -
- ) -} diff --git a/frontend/src/features/alerts/components/alerts-header.tsx b/frontend/src/features/alerts/components/alerts-header.tsx index 886da8e07..23af80642 100644 --- a/frontend/src/features/alerts/components/alerts-header.tsx +++ b/frontend/src/features/alerts/components/alerts-header.tsx @@ -1,6 +1,5 @@ -import { BarChart3, Rows3 } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { cn } from '@/shared/lib/utils' +import { ViewToggle } from './view-toggle' export type AlertsView = 'alerts' | 'overview' @@ -33,27 +32,3 @@ export function AlertsHeader({ ) } - -function ViewToggle({ view, onView }: { view: AlertsView; onView: (v: AlertsView) => void }) { - const { t } = useTranslation() - const opts = [ - { id: 'alerts' as const, icon: Rows3, label: t('alerts.view.alerts') }, - { id: 'overview' as const, icon: BarChart3, label: t('alerts.view.overview') }, - ] - return ( -
- {opts.map(({ id, icon: Icon, label }) => ( - - ))} -
- ) -} diff --git a/frontend/src/features/alerts/components/alerts-table-header.tsx b/frontend/src/features/alerts/components/alerts-table-header.tsx new file mode 100644 index 000000000..ea576a766 --- /dev/null +++ b/frontend/src/features/alerts/components/alerts-table-header.tsx @@ -0,0 +1,29 @@ +import { useTranslation } from 'react-i18next' + +const TH = 'whitespace-nowrap px-3 py-2.5 text-left align-middle font-medium' + +export function AlertsTableHeader({ allChecked, onTogglePage }: { allChecked: boolean; onTogglePage: () => void }) { + const { t } = useTranslation() + return ( + + + + + + + + {t('alerts.table.alert')} + {t('alerts.table.status')} + {t('alerts.table.technique')} + {t('alerts.table.sourceAdversary')} + {t('alerts.table.risk')} + {t('alerts.table.echoes')} + {t('alerts.table.time')} + + + ) +} + +export const ALERTS_TABLE_COLUMN_COUNT = 10 diff --git a/frontend/src/features/alerts/components/alerts-table.tsx b/frontend/src/features/alerts/components/alerts-table.tsx deleted file mode 100644 index d10b79652..000000000 --- a/frontend/src/features/alerts/components/alerts-table.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { ArrowRight, Sparkles, Tag, UserCheck } from 'lucide-react' -import { useTranslation } from 'react-i18next' -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 { TagChip } from './ui-primitives' - -export function AlertsTableHeader({ allChecked, onTogglePage }: { allChecked: boolean; onTogglePage: () => void }) { - const { t } = useTranslation() - return ( -
- -
-
{t('alerts.table.alert')}
-
{t('alerts.table.status')}
-
{t('alerts.table.technique')}
-
{t('alerts.table.sourceAdversary')}
-
{t('alerts.table.risk')}
-
{t('alerts.table.time')}
-
-
- ) -} - -export function AlertRow({ - alert: a, - tagCatalog, - checked, - onToggle, - onOpen, - onCreateRule, -}: { - alert: Alert - tagCatalog: AlertTag[] - checked: boolean - onToggle: () => void - onOpen: () => void - onCreateRule: (alert: Alert) => 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 ( -
- {/* Severity accent — colored left edge so the row's risk reads at a glance. */} - - - -
-
- {a.name || '—'} - {(isAiNote(a.notes) || isAiNote(a.statusObservation)) && ( - - )} - {a.isIncident && ( - - {t('alerts.badge.incident')} - - )} -
-
- - {a.category} - {a.dataSource && ` · ${a.dataSource}`} - - {(a.tags ?? []).slice(0, 2).map((tag) => ( - - ))} - {(a.tags ?? []).length > 2 && ( - - +{(a.tags ?? []).length - 2} - - )} - {a.assignee && ( - - - {a.assignee} - - )} -
-
-
- - {stmLabel} - -
-
- {a.technique || '—'} -
- -
- - {riskOf(a)} - -
-
{relativeTime(a[TS])}
-
- ) -} - -function FlowCell({ source, adversary }: { source?: Side; adversary?: Side }) { - return ( -
- - - -
- ) -} - -function EndpointMini({ ep, accent }: { ep?: Side; accent?: boolean }) { - if (!ep || (!ep.host && !ep.ip && !ep.user)) return - const cc = ep.geolocation?.countryCode - const flag = flagEmoji(cc) - return ( - - {flag && {flag}} - {ep.host || ep.user || ep.ip} - - ) -} diff --git a/frontend/src/features/alerts/components/alerts-toolbar.tsx b/frontend/src/features/alerts/components/alerts-toolbar.tsx index 99d2ec748..3bca9b0ef 100644 --- a/frontend/src/features/alerts/components/alerts-toolbar.tsx +++ b/frontend/src/features/alerts/components/alerts-toolbar.tsx @@ -1,13 +1,12 @@ -import { useEffect, useRef, useState } from 'react' -import { Check, ChevronDown, Download, ListPlus, Lock, Pencil, Plus, RefreshCw, Search, Tag as TagIcon, Trash2, X } from 'lucide-react' -import { useNavigate } from 'react-router-dom' +import { Download, RefreshCw, Search } from 'lucide-react' import { useTranslation } from 'react-i18next' import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' import { TimeRangePicker, type TimeRange } from '@/shared/components/ui/time-range-picker' -import { SELECT_CLS, TAG_COLORS } from '../lib/alert-meta' +import { SELECT_CLS } from '../lib/alert-meta' import type { AlertTag, SeverityKey } from '../types/alert.types' +import { TagFilter } from './tag-filter' export function AlertsToolbar({ search, @@ -86,225 +85,3 @@ export function AlertsToolbar({
) } - -function TagFilter({ - catalog, - selected, - onSelected, - onCreateTag, - onUpdateTag, - onDeleteTag, -}: { - catalog: AlertTag[] - selected: string[] - onSelected: (tags: string[]) => void - onCreateTag: (tagName: string, tagColor: string) => void - onUpdateTag: (id: number, tagName: string, tagColor: string) => void - onDeleteTag: (id: number, tagName: string) => void -}) { - const { t } = useTranslation() - const navigate = useNavigate() - // Deep-link to the tagging-rules page in create mode with this tag pre-selected. - const goToCreateRule = (tg: AlertTag) => - navigate('/threat-management/alerts/tagging-rules', { state: { createWithTag: tg } }) - const [open, setOpen] = useState(false) - const [editingId, setEditingId] = useState(null) - const [editName, setEditName] = useState('') - const [editColor, setEditColor] = useState(TAG_COLORS[5]) - const [name, setName] = useState('') - const [color, setColor] = useState(TAG_COLORS[5]) - 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]) - - const trimmed = name.trim() - const exists = catalog.some((tg) => tg.tagName.toLowerCase() === trimmed.toLowerCase()) - const canCreate = trimmed.length > 0 && !exists - const create = () => { - if (!canCreate) return - onCreateTag(trimmed, color) - setName('') - } - - const toggle = (name: string) => - onSelected(selected.includes(name) ? selected.filter((t) => t !== name) : [...selected, name]) - const startEdit = (tg: AlertTag) => { - setEditingId(tg.id) - setEditName(tg.tagName) - setEditColor(tg.tagColor || TAG_COLORS[5]) - } - const saveEdit = () => { - const n = editName.trim() - if (!n || editingId == null) return - onUpdateTag(editingId, n, editColor) - setEditingId(null) - } - - return ( -
- - {open && ( -
- {selected.length > 0 && ( - - )} -
- {catalog.length === 0 && ( -
{t('alerts.tagFilter.noTags')}
- )} - {catalog.map((tg) => { - const on = selected.includes(tg.tagName) - if (editingId === tg.id) { - return ( -
-
- setEditName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') saveEdit() - if (e.key === 'Escape') setEditingId(null) - }} - autoFocus - className="h-7 min-w-0 flex-1 rounded-md border border-input bg-background px-2 text-xs focus-visible:border-ring focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" - /> - - -
-
- {TAG_COLORS.map((c) => ( -
-
- ) - } - return ( -
- - - - {!tg.systemOwner && ( - <> - - - - )} - - {tg.systemOwner && ( - - )} -
- ) - })} -
-
-
- {t('alerts.tagEditor.createTag')} -
-
- setName(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && create()} - placeholder={t('alerts.tagEditor.newTagName')} - className="h-7 min-w-0 flex-1 rounded-md border border-input bg-background px-2 text-xs focus-visible:border-ring focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" - /> - -
-
- {TAG_COLORS.map((c) => ( -
- {exists && trimmed.length > 0 && ( -
{t('alerts.tagEditor.alreadyExists')}
- )} -
-
- )} -
- ) -} diff --git a/frontend/src/features/alerts/components/assignee-menu.tsx b/frontend/src/features/alerts/components/assignee-menu.tsx new file mode 100644 index 000000000..8dce3f54a --- /dev/null +++ b/frontend/src/features/alerts/components/assignee-menu.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react' +import { ChevronDown, UserPlus } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { cn } from '@/shared/lib/utils' +import { usersHttpService } from '@/features/team/services/team-http.service' +import type { UserListItem } from '@/features/team/types/team.types' +import { Menu } from './menu' + +// Assign / reassign / clear an alert's owner. Loads the user list lazily on first +// open so the dropdown isn't fetched for every alert row. +export function AssigneeMenu({ current, onAssign }: { current?: string; onAssign: (assignee: string) => void }) { + const { t } = useTranslation() + const [users, setUsers] = useState(null) + const [loaded, setLoaded] = useState(false) + + useEffect(() => { + if (!loaded) return + let cancelled = false + usersHttpService + .list({ page_size: 200 }) + .then((r) => { + if (!cancelled) setUsers(r.data ?? []) + }) + .catch(() => { + if (!cancelled) setUsers([]) + }) + return () => { + cancelled = true + } + }, [loaded]) + + const label = (u: UserListItem) => + [u.first_name, u.last_name].filter(Boolean).join(' ') || u.login + + return ( +
setLoaded(true)} onFocusCapture={() => setLoaded(true)} onClickCapture={() => setLoaded(true)}> + + + {current ? current : t('alerts.drawer.assign')} + + + } + > + {users == null ? ( +
{t('alerts.drawer.loadingUsers')}
+ ) : ( + <> + {users.map((u) => ( + + ))} + {current && ( + + )} + + )} +
+
+ ) +} diff --git a/frontend/src/features/alerts/components/center.tsx b/frontend/src/features/alerts/components/center.tsx new file mode 100644 index 000000000..0b1fee3ce --- /dev/null +++ b/frontend/src/features/alerts/components/center.tsx @@ -0,0 +1,3 @@ +export function Center({ children }: { children: React.ReactNode }) { + return
{children}
+} 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/components/endpoint-mini.tsx b/frontend/src/features/alerts/components/endpoint-mini.tsx new file mode 100644 index 000000000..dbad6bcd0 --- /dev/null +++ b/frontend/src/features/alerts/components/endpoint-mini.tsx @@ -0,0 +1,15 @@ +import { cn } from '@/shared/lib/utils' +import { flagEmoji } from '../lib/alert-meta' +import type { Side } from '../types/alert.types' + +export function EndpointMini({ ep, accent }: { ep?: Side; accent?: boolean }) { + if (!ep || (!ep.host && !ep.ip && !ep.user)) return + const cc = ep.geolocation?.countryCode + const flag = flagEmoji(cc) + return ( + + {flag && {flag}} + {ep.host || ep.user || ep.ip} + + ) +} diff --git a/frontend/src/features/alerts/components/flow-cell.tsx b/frontend/src/features/alerts/components/flow-cell.tsx new file mode 100644 index 000000000..431e48848 --- /dev/null +++ b/frontend/src/features/alerts/components/flow-cell.tsx @@ -0,0 +1,13 @@ +import { ArrowRight } from 'lucide-react' +import type { Side } from '../types/alert.types' +import { EndpointMini } from './endpoint-mini' + +export function FlowCell({ source, adversary }: { source?: Side; adversary?: Side }) { + return ( +
+ + + +
+ ) +} diff --git a/frontend/src/features/alerts/components/history-tab.tsx b/frontend/src/features/alerts/components/history-tab.tsx new file mode 100644 index 000000000..88862644e --- /dev/null +++ b/frontend/src/features/alerts/components/history-tab.tsx @@ -0,0 +1,78 @@ +import { useTranslation } from 'react-i18next' +import type { TFunction } from 'i18next' +import { relativeTime } from '../lib/alert-meta' +import { isAiNote } from '../lib/ai-note' +import { STATUS_BY_INT, type Alert } from '../types/alert.types' + +const ACTION_KEYS = ['UPDATE_STATUS', 'UPDATE_TAGS', 'UPDATE_NOTES', 'UPDATE_SOLUTION', 'MARK_AS_INCIDENT'] +function actionLabel(a: string | undefined, t: TFunction) { + if (a && ACTION_KEYS.includes(a)) return t(`alerts.history.actions.${a}`) + return (a ?? 'change').replace(/_/g, ' ').toLowerCase() +} + +// A clean one-line detail — never dumps the raw AI assessment or the newValue JSON. +function historyDetail(h: { message?: string; newValue?: string }, t: TFunction): string { + const msg = (h.message ?? '').trim() + if (msg && !isAiNote(msg) && !msg.startsWith('{')) return msg + try { + const v = JSON.parse(h.newValue || '{}') as Record + const parts: string[] = [] + if (typeof v.status === 'number') { + const stKey = STATUS_BY_INT[v.status] + parts.push( + t('alerts.history.detail.statusTo', { status: stKey ? t(`alerts.status.${stKey}`) : String(v.status) }) + ) + } + if (v.tags != null) + parts.push( + t('alerts.history.detail.tags', { + tags: Array.isArray(v.tags) ? (v.tags as string[]).join(', ') : String(v.tags), + }) + ) + if (typeof v.statusObservation === 'string' && v.statusObservation) + parts.push( + isAiNote(v.statusObservation) + ? t('alerts.history.detail.aiAdded') + : t('alerts.history.detail.observationAdded') + ) + if (v.notes != null && !isAiNote(String(v.notes))) parts.push(t('alerts.history.detail.notesUpdated')) + return parts.join(' · ') + } catch { + return '' + } +} + +export function HistoryTab({ alert: a }: { alert: Alert }) { + const { t } = useTranslation() + const history = a.history ?? [] + if (history.length === 0) return

{t('alerts.history.empty')}

+ return ( +
+ {history + .slice() + .reverse() + .map((h, i) => { + const detail = historyDetail(h, t) + return ( +
+
+ {actionLabel(h.action, t)} + {relativeTime(h.timestamp)} +
+ {(detail || h.user) && ( +
+ {detail} + {h.user && ( + + {detail ? ' · ' : ''} + {t('alerts.history.by', { user: h.user })} + + )} +
+ )} +
+ ) + })} +
+ ) +} diff --git a/frontend/src/features/alerts/components/menu.tsx b/frontend/src/features/alerts/components/menu.tsx new file mode 100644 index 000000000..21697ecf2 --- /dev/null +++ b/frontend/src/features/alerts/components/menu.tsx @@ -0,0 +1,24 @@ +import { useEffect, useRef, useState } from 'react' + +export function Menu({ trigger, children }: { trigger: React.ReactNode; children: React.ReactNode }) { + const [open, setOpen] = useState(false) + 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]) + return ( +
+ + {open && ( +
setOpen(false)} className="absolute left-0 top-full z-30 mt-1 max-h-64 w-48 overflow-y-auto rounded-md border border-border bg-popover py-1 shadow-lg"> + {children} +
+ )} +
+ ) +} diff --git a/frontend/src/features/alerts/components/party-card.tsx b/frontend/src/features/alerts/components/party-card.tsx new file mode 100644 index 000000000..070fbee85 --- /dev/null +++ b/frontend/src/features/alerts/components/party-card.tsx @@ -0,0 +1,30 @@ +import { useTranslation } from 'react-i18next' +import { cn } from '@/shared/lib/utils' +import { flagEmoji } from '../lib/alert-meta' +import type { Side } from '../types/alert.types' +import { Row } from './row' + +export function PartyCard({ title, ep, accent }: { title: string; ep?: Side; accent?: boolean }) { + const { t } = useTranslation() + return ( +
+

{title}

+ {!ep ? ( +

{t('alerts.party.noData')}

+ ) : ( +
+ {ep.ip && {ep.ip}} + {ep.host && {ep.host}} + {ep.user && {ep.user}} + {ep.domain && {ep.domain}} + {ep.geolocation?.country && ( + + {flagEmoji(ep.geolocation.countryCode)} {ep.geolocation.country} + {ep.geolocation.city ? ` · ${ep.geolocation.city}` : ''} + + )} +
+ )} +
+ ) +} diff --git a/frontend/src/features/alerts/components/row.tsx b/frontend/src/features/alerts/components/row.tsx new file mode 100644 index 000000000..946df1559 --- /dev/null +++ b/frontend/src/features/alerts/components/row.tsx @@ -0,0 +1,8 @@ +export function Row({ k, children }: { k: string; children: React.ReactNode }) { + return ( + <> +
{k}
+
{children}
+ + ) +} diff --git a/frontend/src/features/alerts/components/section.tsx b/frontend/src/features/alerts/components/section.tsx new file mode 100644 index 000000000..5b569ced3 --- /dev/null +++ b/frontend/src/features/alerts/components/section.tsx @@ -0,0 +1,8 @@ +export function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ) +} 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}

+ +
+ +
+ +