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
134 changes: 111 additions & 23 deletions frontend/src/features/parsing-filters/components/FilterFormDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Code2, FlaskConical, LayoutList, Loader2, Lock, Trash2, X } from 'lucide-react'
import { Code2, ChevronRight, FlaskConical, LayoutList, Loader2, Lock, Plus, Trash2, X } from 'lucide-react'
import { cn } from '@/shared/lib/utils'
import { Button } from '@/shared/components/ui/button'
import { Input } from '@/shared/components/ui/input'
Expand All @@ -14,7 +14,7 @@ import type { DataTypeOption, Pipeline } from '@/features/data-processing/types/
import { regexPatternsHttpService, type RegexPattern } from '@/features/regex-patterns/services/regex-patterns-http.service'
import { TestPlaygroundModal } from '@/features/playground/components/TestPlaygroundModal'
import { PlatformBroadcastButton, broadcast, BULK_PATHS, type BulkSelector } from '@/features/platform-broadcast'
import { displayName, emptyModel, parseFilter, sanitizeFileName, serializeFilter, type FilterModel } from '../lib/filter-model'
import { displayName, emptyEntry, emptyModel, modelDataTypes, parseFilter, sanitizeFileName, serializeFilter, type EntryModel, type FilterModel } from '../lib/filter-model'
import { VisualFilterEditor } from './VisualFilterEditor'
import { PatternInsertButton } from './PatternInsertButton'
import { DataTypeMultiSelect } from './DataTypeMultiSelect'
Expand Down Expand Up @@ -48,7 +48,12 @@ export function FilterFormDrawer({ filter, creating, onClose, onSaved }: Props)
// Visual ↔ Code. Content (YAML string) stays canonical; the visual editor
// serializes back into it on every change.
const [mode, setMode] = useState<'visual' | 'code'>('code')
const [model, setModel] = useState<FilterModel>({ dataTypes: [], order: filter.order || 100, steps: [] })
const [model, setModel] = useState<FilterModel>({ entries: [emptyEntry([], filter.order || 100)] })
// Which entries are folded in the visual editor — only rendered for the
// header of a multi-entry file (more than 2), so a new index is "collapsed"
// until the user opens it.
const [collapsed, setCollapsed] = useState<Set<number>>(new Set())
const manyEntries = model.entries.length > 1
const [dataTypeOptions, setDataTypeOptions] = useState<DataTypeOption[]>([])
const [patternOptions, setPatternOptions] = useState<RegexPattern[]>([])
const yamlTaRef = useRef<HTMLTextAreaElement | null>(null)
Expand Down Expand Up @@ -96,6 +101,21 @@ export function FilterFormDrawer({ filter, creating, onClose, onSaved }: Props)
setContent(serializeFilter(m))
}

const setEntry = (i: number, e: EntryModel) => {
const entries = model.entries.map((x, k) => (k === i ? e : x))
onModelChange({ ...model, entries })
}
const removeEntry = (i: number) => {
const entries = model.entries.filter((_, k) => k !== i)
if (entries.length === 0) return
onModelChange({ ...model, entries })
}
const addEntry = () => {
const last = model.entries[model.entries.length - 1]
const order = (last?.order ?? 0) + 1
onModelChange({ ...model, entries: [...model.entries, emptyEntry([], order)] })
}

const dirty = creating ? name.trim() !== '' || content.trim() !== '' : content !== filter.content

const save = async () => {
Expand Down Expand Up @@ -217,25 +237,93 @@ export function FilterFormDrawer({ filter, creating, onClose, onSaved }: Props)
</div>
</div>
{mode === 'visual' ? (
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto rounded-md border border-input bg-background/20 p-3">
<div className="space-y-1.5">
<label className="block text-xs font-medium text-foreground/80">{t('parsingFilters.visual.dataTypes')}</label>
<DataTypeMultiSelect
values={model.dataTypes}
options={dataTypeOptions}
readOnly={readOnly}
onChange={(dataTypes) => onModelChange({ ...model, dataTypes })}
/>
</div>
<div className="space-y-2">
<label className="block text-xs font-medium text-foreground/80">{t('parsingFilters.visual.steps')}</label>
<VisualFilterEditor
value={model.steps}
readOnly={readOnly}
patternOptions={patternOptions}
onChange={(steps) => onModelChange({ ...model, steps })}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto rounded-md border border-input bg-background/20 p-0">
{model.entries.map((entry, i) => {
const isCollapsed = manyEntries && collapsed.has(i)
return (
<div
key={i}
className={cn(
'border-b border-border/70 p-0 last:border-b-0',
!manyEntries && 'rounded-lg bg-card/40 p-3',
)}
>
<div className="flex w-full items-center justify-between">
<span className="flex min-w-0 items-center gap-1 text-xs font-semibold text-muted-foreground ">
{manyEntries && (
<button
type="button"
onClick={() => {
const next = new Set(collapsed)
if (next.has(i)) next.delete(i)
else next.add(i)
setCollapsed(next)
}}
className="shrink-0 rounded p-5 text-muted-foreground hover:bg-muted hover:text-foreground"
title={isCollapsed ? t('parsingFilters.visual.expand') : t('parsingFilters.visual.collapse')}
>
<ChevronRight size={13} className={cn('transition-transform scale-1.5', !isCollapsed && 'rotate-90')} />
</button>
)}
<span className="truncate">
{t('parsingFilters.visual.entry', { n: i + 1 })}
{typeof entry.order === 'number' && entry.order !== 0 && (
<span className="ml-1.5 font-mono text-[11px] text-foreground/60">{t('parsingFilters.visual.order', { n: entry.order })}</span>
)}
</span>
</span>
{!readOnly && model.entries.length > 1 && (
<button
type="button"
onClick={() => removeEntry(i)}
title={t('parsingFilters.visual.removeEntry')}
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-red-500/10 hover:text-red-500"
>
<Trash2 size={13} />
</button>
)}
</div>
<div data-state={isCollapsed ? 'closed' : 'open'} className="data-[state=closed]:hidden">
<div className="space-y-3 p-3 pt-2">
<div className="space-y-1.5">
<label className="block text-xs font-medium text-foreground/80">{t('parsingFilters.visual.dataTypes')}</label>
<DataTypeMultiSelect
values={entry.dataTypes}
options={dataTypeOptions}
readOnly={readOnly}
onChange={(dataTypes) => setEntry(i, { ...entry, dataTypes })}
/>
</div>
{!readOnly && (
<div className="space-y-1">
<label className="block text-xs font-medium text-foreground/80">{t('parsingFilters.visual.orderLabel')}</label>
<Input
type="number"
value={entry.order}
onChange={(e) => setEntry(i, { ...entry, order: Number(e.target.value) || 0 })}
className="h-8 w-24 font-mono text-[12px]"
/>
</div>
)}
<div className="space-y-2">
<label className="block text-xs font-medium text-foreground/80">{t('parsingFilters.visual.steps')}</label>
<VisualFilterEditor
value={entry.steps}
readOnly={readOnly}
patternOptions={patternOptions}
onChange={(steps) => setEntry(i, { ...entry, steps })}
/>
</div>
</div>
</div>
</div>
)
})}
{!readOnly && (
<Button type="button" variant="outline" size="sm" onClick={() => addEntry()}>
<Plus size={12} className="mr-1" /> {t('parsingFilters.visual.addEntry')}
</Button>
)}
</div>
) : (
<div className="relative flex min-h-0 flex-1 flex-col">
Expand Down Expand Up @@ -328,7 +416,7 @@ export function FilterFormDrawer({ filter, creating, onClose, onSaved }: Props)
<TestPlaygroundModal
mode="pipeline"
titleKey="playground.titleFilter"
dataTypeOptions={model.dataTypes}
dataTypeOptions={modelDataTypes(model)}
draftContent={content}
onClose={() => setShowTestModal(false)}
/>
Expand Down
84 changes: 50 additions & 34 deletions frontend/src/features/parsing-filters/lib/filter-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function sanitizeFileName(name: string): string {

/**
* Structured model of a parsing filter, mirroring the Event Processor's pipeline
* schema (go-sdk plugins.Config) — one dataTypes/order/steps entry per file:
* schema (go-sdk plugins.Config) — one or more dataTypes/order/steps entries per file:
*
* pipeline:
* - dataTypes: [<string>, ...] # a pipeline can be reused across several dataTypes
Expand Down Expand Up @@ -67,12 +67,18 @@ export interface Step {
config: Record<string, unknown>
}

export interface FilterModel {
export interface EntryModel {
dataTypes: string[]
order: number
steps: Step[]
}

/** One file may hold several pipeline entries (e.g. a CEF stage followed by a
* LEEF stage, each with its own dataTypes and order). */
export interface FilterModel {
entries: EntryModel[]
}

export type ParseResult = { ok: true; model: FilterModel } | { ok: false; error: string }

function isRecord(v: unknown): v is Record<string, unknown> {
Expand All @@ -95,48 +101,54 @@ export function parseFilter(content: string): ParseResult {
if (!Array.isArray(rawPipeline) || rawPipeline.length === 0) {
return { ok: false, error: '"pipeline" must be a non-empty list' }
}
if (rawPipeline.length > 1) {
return { ok: false, error: `this editor only supports one pipeline entry per file (found ${rawPipeline.length})` }
}
const rawBlock = rawPipeline[0]
if (!isRecord(rawBlock)) return { ok: false, error: 'the pipeline entry must be a mapping' }

const steps: Step[] = []
const rawSteps = rawBlock.steps
if (rawSteps != null) {
if (!Array.isArray(rawSteps)) return { ok: false, error: '"steps" must be a list' }
for (const rawStep of rawSteps) {
if (!isRecord(rawStep)) return { ok: false, error: 'each step must be a mapping' }
const keys = Object.keys(rawStep)
if (keys.length !== 1) {
return { ok: false, error: `a step must have exactly one processor (found ${keys.length})` }
}
const kind = keys[0] as StepKind
if (!STEP_KINDS.includes(kind)) {
return { ok: false, error: `unknown processor "${kind}"` }
const entries: EntryModel[] = []
for (const rawBlock of rawPipeline) {
if (!isRecord(rawBlock)) return { ok: false, error: 'each pipeline entry must be a mapping' }

const steps: Step[] = []
const rawSteps = rawBlock.steps
if (rawSteps != null) {
if (!Array.isArray(rawSteps)) return { ok: false, error: '"steps" must be a list' }
for (const rawStep of rawSteps) {
if (!isRecord(rawStep)) return { ok: false, error: 'each step must be a mapping' }
const keys = Object.keys(rawStep)
if (keys.length !== 1) {
return { ok: false, error: `a step must have exactly one processor (found ${keys.length})` }
}
const kind = keys[0] as StepKind
if (!STEP_KINDS.includes(kind)) {
return { ok: false, error: `unknown processor "${kind}"` }
}
const body = rawStep[kind]
steps.push({ kind, config: isRecord(body) ? { ...body } : {} })
}
const body = rawStep[kind]
steps.push({ kind, config: isRecord(body) ? { ...body } : {} })
}

const rawDataTypes = rawBlock.dataTypes
const dataTypes = Array.isArray(rawDataTypes) ? rawDataTypes.filter((d): d is string => typeof d === 'string') : []
const order = typeof rawBlock.order === 'number' ? rawBlock.order : 0

entries.push({ dataTypes, order, steps })
}

const rawDataTypes = rawBlock.dataTypes
const dataTypes = Array.isArray(rawDataTypes) ? rawDataTypes.filter((d): d is string => typeof d === 'string') : []
const order = typeof rawBlock.order === 'number' ? rawBlock.order : 0
return { ok: true, model: { entries } }
}

return { ok: true, model: { dataTypes, order, steps } }
/** All dataTypes across every entry — the test playground picks from the union. */
export function modelDataTypes(model: FilterModel): string[] {
const seen = new Set<string>()
for (const e of model.entries) for (const d of e.dataTypes) seen.add(d)
return [...seen]
}

/** Serialize the structured model back to YAML the Event Processor accepts. */
export function serializeFilter(model: FilterModel): string {
const doc = {
pipeline: [
{
dataTypes: model.dataTypes,
order: model.order,
steps: model.steps.map((s) => ({ [s.kind]: pruneEmpty(s.config) })),
},
],
pipeline: model.entries.map((e) => ({
dataTypes: e.dataTypes,
order: e.order,
steps: e.steps.map((s) => ({ [s.kind]: pruneEmpty(s.config) })),
})),
}
return dump(doc, { indent: 2, lineWidth: -1, noRefs: true, quotingType: '"' })
}
Expand Down Expand Up @@ -174,5 +186,9 @@ export function emptyStep(kind: StepKind): Step {
}

export function emptyModel(dataTypes: string[], order: number): FilterModel {
return { entries: [{ dataTypes, order, steps: [] }] }
}

export function emptyEntry(dataTypes: string[] = [], order = 0): EntryModel {
return { dataTypes, order, steps: [] }
}
9 changes: 8 additions & 1 deletion frontend/src/shared/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -4751,7 +4751,14 @@
"patternDefRequired": "Definition ist erforderlich",
"parameters": "Parameter",
"paramsKey": "Schlüssel",
"paramsValue": "Wert"
"paramsValue": "Wert",
"entry": "Eintrag {{n}}",
"order": "Reihenfolge {{n}}",
"orderLabel": "Reihenfolge",
"removeEntry": "Eintrag entfernen",
"addEntry": "Pipeline-Eintrag hinzufügen",
"collapse": "Zuklappen",
"expand": "Ausklappen"
},
"allDataTypes": "Alle Datentypen",
"testPipeline": "Test pipeline"
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/shared/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4985,7 +4985,14 @@
"patternDefRequired": "Definition is required",
"parameters": "Parameters",
"paramsKey": "Key",
"paramsValue": "Value"
"paramsValue": "Value",
"entry": "Entry {{n}}",
"order": "order {{n}}",
"orderLabel": "Order",
"removeEntry": "Remove entry",
"addEntry": "Add pipeline entry",
"collapse": "Collapse",
"expand": "Expand"
},
"allDataTypes": "All data types",
"testPipeline": "Test pipeline"
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/shared/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -4685,7 +4685,14 @@
"patternDefRequired": "La definición es obligatoria",
"parameters": "Parámetros",
"paramsKey": "Clave",
"paramsValue": "Valor"
"paramsValue": "Valor",
"entry": "Entrada {{n}}",
"order": "orden {{n}}",
"orderLabel": "Orden",
"removeEntry": "Eliminar entrada",
"addEntry": "Añadir entrada de pipeline",
"collapse": "Contraer",
"expand": "Expandir"
},
"allDataTypes": "Todos los tipos de dato",
"testPipeline": "Test pipeline"
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/shared/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -4751,7 +4751,14 @@
"patternDefRequired": "La définition est requise",
"parameters": "Paramètres",
"paramsKey": "Clé",
"paramsValue": "Valeur"
"paramsValue": "Valeur",
"entry": "Entrée {{n}}",
"order": "ordre {{n}}",
"orderLabel": "Ordre",
"removeEntry": "Supprimer l’entrée",
"addEntry": "Ajouter une entrée de pipeline",
"collapse": "Replier",
"expand": "Déplier"
},
"allDataTypes": "Tous les types de données",
"testPipeline": "Test pipeline"
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/shared/i18n/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -4751,7 +4751,14 @@
"patternDefRequired": "La definizione è obbligatoria",
"parameters": "Parametri",
"paramsKey": "Chiave",
"paramsValue": "Valore"
"paramsValue": "Valore",
"entry": "Vocetta {{n}}",
"order": "ordine {{n}}",
"orderLabel": "Ordine",
"removeEntry": "Rimuovi vocetta",
"addEntry": "Aggiungi vocetta di pipeline",
"collapse": "Comprimi",
"expand": "Espandi"
},
"allDataTypes": "Tutti i tipi di dati",
"testPipeline": "Test pipeline"
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/shared/i18n/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -4685,7 +4685,14 @@
"patternDefRequired": "A definição é obrigatória",
"parameters": "Parâmetros",
"paramsKey": "Chave",
"paramsValue": "Valor"
"paramsValue": "Valor",
"entry": "Entrada {{n}}",
"order": "ordem {{n}}",
"orderLabel": "Ordem",
"removeEntry": "Remover entrada",
"addEntry": "Adicionar entrada de pipeline",
"collapse": "Recolher",
"expand": "Expandir"
},
"allDataTypes": "Todos os tipos de dado",
"testPipeline": "Test pipeline"
Expand Down
Loading
Loading