Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/modules/alerts/connectors/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions backend/modules/alerts/connectors/usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
34 changes: 34 additions & 0 deletions backend/modules/alerts/handler/alerts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
17 changes: 17 additions & 0 deletions backend/modules/alerts/repository/alert_os.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

31 changes: 31 additions & 0 deletions backend/modules/alerts/repository/osquery.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions backend/modules/alerts/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions backend/modules/alerts/usecase/alert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
127 changes: 127 additions & 0 deletions frontend/src/features/alerts/components/add-filter-button.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((v) => !v)}
className="inline-flex items-center gap-1.5 rounded-full border border-dashed border-border px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<ListFilter size={12} /> {t('alerts.filters.add')}
</button>
{open && (
<div className="absolute left-0 top-full z-30 mt-1 w-80 rounded-md border border-border bg-popover p-3 shadow-lg">
<div className="space-y-2">
<select value={field} onChange={(e) => setField(e.target.value)} className={cn(SELECT_CLS, 'w-full')}>
{FILTER_FIELDS.map((f) => (
<option key={f.field} value={f.field}>
{t(`alerts.fields.${fieldKey(f.field)}`)}
</option>
))}
</select>
<select value={operator} onChange={(e) => setOperator(e.target.value)} className={cn(SELECT_CLS, 'w-full')}>
{FILTER_OPS.map((o) => (
<option key={o.id} value={o.id}>
{t(`alerts.ops.${o.id}`)}
</option>
))}
</select>

{needsValue ? (
<>
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
value={vq}
onChange={(e) => setVq(e.target.value)}
placeholder={t('alerts.filters.filterValues')}
className="h-8 pl-8 text-xs"
autoFocus
/>
</div>
<div className="max-h-48 overflow-y-auto rounded-md border border-border">
{loadingValues ? (
<div className="flex items-center gap-1.5 px-3 py-3 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t('alerts.filters.loadingValues')}
</div>
) : filtered.length === 0 ? (
<div className="px-3 py-3 text-xs text-muted-foreground">{t('alerts.filters.noValues')}</div>
) : (
filtered.map((v) => (
<button
key={v.value}
onClick={() => add(v.value)}
className="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-xs hover:bg-muted"
>
<span className="truncate font-mono">{v.value || t('alerts.filters.empty')}</span>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">{v.count.toLocaleString()}</span>
</button>
))
)}
</div>
<p className="text-[10px] text-muted-foreground">{t('alerts.filters.pickValue')}</p>
</>
) : (
<div className="flex justify-end gap-2 pt-1">
<Button variant="outline" size="sm" onClick={() => setOpen(false)}>
{t('alerts.filters.cancel')}
</Button>
<Button size="sm" onClick={() => add('')}>
{t('alerts.filters.addBtn')}
</Button>
</div>
)}
</div>
</div>
)}
</div>
)
}
Loading
Loading