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
312 changes: 312 additions & 0 deletions frontend/src/features/threat-intel/components/FiltersPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
import { useState } from 'react'
import { Filter, X } from 'lucide-react'
import { Button } from '@/shared/components/ui/button'
import { Input } from '@/shared/components/ui/input'
import { cn } from '@/shared/lib/utils'
import {
DateRangePickerDialog,
formatRange,
type DateRangeValue,
} from '@/shared/components/ui/date-range-picker'
import type {
EntityType,
AdvancedSearchRequest,
AdvancedCondition,
} from '@/features/threat-intel/domain/threat-intel.types'

const ALL_TYPES: EntityType[] = [
'ip', 'domain', 'hostname', 'url',
'md5', 'sha1', 'sha256', 'sha3-256',
'cve', 'email-address', 'threat', 'malware',
]

export interface FiltersState {
types: EntityType[]
reputation: { min: number; max: number } | null
accuracy: { min: number; max: number } | null
tagsInclude: string[]
tagsExclude: string[]
dateFrom: string | null
dateTo: string | null
}

export const EMPTY_FILTERS: FiltersState = {
types: [],
reputation: null,
accuracy: null,
tagsInclude: [],
tagsExclude: [],
dateFrom: null,
dateTo: null,
}

export function filtersToRequest(
state: FiltersState,
extra?: AdvancedSearchRequest,
): AdvancedSearchRequest {
const must: AdvancedCondition[] = [...(extra?.query?.must ?? [])]
const mustNot: AdvancedCondition[] = [...(extra?.query?.must_not ?? [])]
const filter: AdvancedCondition[] = [...(extra?.query?.filter ?? [])]
const should: AdvancedCondition[] = [...(extra?.query?.should ?? [])]

if (state.types.length > 0) {
must.push({ terms: { type: state.types } })
}
if (state.reputation !== null) {
filter.push({
range: {
reputation: {
gte: String(state.reputation.min),
lte: String(state.reputation.max),
},
},
})
}
if (state.accuracy !== null) {
filter.push({
range: {
accuracy: {
gte: String(state.accuracy.min),
lte: String(state.accuracy.max),
},
},
})
}
if (state.tagsInclude.length > 0) {
must.push({ terms: { tags: state.tagsInclude } })
}
if (state.tagsExclude.length > 0) {
mustNot.push({ terms: { tags: state.tagsExclude } })
}
if (state.dateFrom || state.dateTo) {
const rangeVal: { gte?: string; lte?: string } = {}
if (state.dateFrom) rangeVal.gte = state.dateFrom
if (state.dateTo) rangeVal.lte = state.dateTo
filter.push({ range: { '@timestamp': rangeVal } })
}

const query: AdvancedSearchRequest['query'] = {}
if (must.length) query.must = must
if (should.length) query.should = should
if (mustNot.length) query.must_not = mustNot
if (filter.length) query.filter = filter

return {
query: Object.keys(query).length ? query : undefined,
aggs: extra?.aggs,
}
}

function countActive(state: FiltersState): number {
let n = 0
if (state.types.length) n++
if (state.reputation) n++
if (state.accuracy) n++
if (state.tagsInclude.length) n++
if (state.tagsExclude.length) n++
if (state.dateFrom || state.dateTo) n++
return n
}

export interface FiltersPanelProps {
value: FiltersState
onChange: (next: FiltersState) => void
onApply: (request: AdvancedSearchRequest) => void
onClear: () => void
onClose?: () => void
}

export function FiltersPanel({ value, onChange, onApply, onClear, onClose }: FiltersPanelProps) {
const [datePickerOpen, setDatePickerOpen] = useState(false)

const dateRangeValue: DateRangeValue = {
from: value.dateFrom ? new Date(value.dateFrom) : null,
to: value.dateTo ? new Date(value.dateTo) : null,
}

const toggleType = (t: EntityType) => {
const next = value.types.includes(t)
? value.types.filter((x) => x !== t)
: [...value.types, t]
onChange({ ...value, types: next })
}

const setRepField = (field: 'min' | 'max', raw: string) => {
const n = raw === '' ? (field === 'min' ? -3 : 3) : Math.max(-3, Math.min(3, Number(raw)))
const base = value.reputation ?? { min: -3, max: 3 }
const next = { ...base, [field]: n }
onChange({ ...value, reputation: next.min === -3 && next.max === 3 ? null : next })
}

const setAccField = (field: 'min' | 'max', raw: string) => {
const n = raw === '' ? (field === 'min' ? 0 : 3) : Math.max(0, Math.min(3, Number(raw)))
const base = value.accuracy ?? { min: 0, max: 3 }
const next = { ...base, [field]: n }
onChange({ ...value, accuracy: next.min === 0 && next.max === 3 ? null : next })
}

const parseTags = (raw: string) =>
raw.split(',').map((s) => s.trim()).filter(Boolean)

const active = countActive(value)

return (
<div className="rounded-xl border border-border bg-card p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-[11px] uppercase tracking-wider text-muted-foreground">
<Filter size={12} className="text-fuchsia-500" />
Filters
{active > 0 && (
<span className="rounded-full bg-fuchsia-500/20 px-1.5 py-0.5 text-[10px] text-fuchsia-400">
{active}
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={onClear}>
Clear
</Button>
<Button size="sm" onClick={() => onApply(filtersToRequest(value))}>
Apply
</Button>
{onClose && (
<button
type="button"
onClick={onClose}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X size={14} />
</button>
)}
</div>
</div>

<div className="space-y-1">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Types</p>
<div className="flex flex-wrap gap-1.5">
{ALL_TYPES.map((t) => (
<button
key={t}
type="button"
onClick={() => toggleType(t)}
className={cn(
'rounded-full border px-2.5 py-0.5 text-xs transition-colors',
value.types.includes(t)
? 'border-fuchsia-500 bg-fuchsia-500/20 text-fuchsia-300'
: 'border-border text-muted-foreground hover:border-fuchsia-400 hover:text-foreground',
)}
>
{t}
</button>
))}
</div>
</div>

<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">
Reputation <span className="normal-case">(−3 to 3)</span>
</p>
<div className="flex items-center gap-2">
<Input
type="number"
min={-3}
max={3}
placeholder="min"
value={value.reputation?.min ?? ''}
onChange={(e) => setRepField('min', e.target.value)}
className="h-8 text-sm"
/>
<span className="text-muted-foreground">–</span>
<Input
type="number"
min={-3}
max={3}
placeholder="max"
value={value.reputation?.max ?? ''}
onChange={(e) => setRepField('max', e.target.value)}
className="h-8 text-sm"
/>
</div>
</div>

<div className="space-y-1">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">
Accuracy <span className="normal-case">(0 to 3)</span>
</p>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
max={3}
placeholder="min"
value={value.accuracy?.min ?? ''}
onChange={(e) => setAccField('min', e.target.value)}
className="h-8 text-sm"
/>
<span className="text-muted-foreground">–</span>
<Input
type="number"
min={0}
max={3}
placeholder="max"
value={value.accuracy?.max ?? ''}
onChange={(e) => setAccField('max', e.target.value)}
className="h-8 text-sm"
/>
</div>
</div>
</div>

<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Tags include</p>
<Input
placeholder="tag1, tag2"
value={value.tagsInclude.join(', ')}
onChange={(e) => onChange({ ...value, tagsInclude: parseTags(e.target.value) })}
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Tags exclude</p>
<Input
placeholder="tag1, tag2"
value={value.tagsExclude.join(', ')}
onChange={(e) => onChange({ ...value, tagsExclude: parseTags(e.target.value) })}
className="h-8 text-sm"
/>
</div>
</div>

<div className="space-y-1">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Date range</p>
<button
type="button"
onClick={() => setDatePickerOpen(true)}
className={cn(
'w-full rounded-md border border-border bg-background px-3 py-1.5 text-left text-sm transition-colors hover:border-fuchsia-400',
!value.dateFrom && !value.dateTo && 'text-muted-foreground',
)}
>
{formatRange(dateRangeValue, 'Pick a date range…')}
</button>
</div>

<DateRangePickerDialog
open={datePickerOpen}
value={dateRangeValue}
title="Date range (@timestamp)"
onClose={() => setDatePickerOpen(false)}
onConfirm={(next) => {
onChange({
...value,
dateFrom: next.from ? next.from.toISOString() : null,
dateTo: next.to ? next.to.toISOString() : null,
})
setDatePickerOpen(false)
}}
/>
</div>
)
}
4 changes: 2 additions & 2 deletions frontend/src/features/threat-intel/components/IocRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { MoreHorizontal } from 'lucide-react'
import { cn } from '@/shared/lib/utils'
import { searchItemValue, type EntitySummary } from '../domain/threat-intel.types'
import { REPUTATION_STYLE, reputationLabelKey, reputationTone, typeMeta } from './utils/severity-style'
import { relativeTime } from './utils/time-format'
import { absTimestamp } from './utils/time-format'

interface IocRowProps {
ioc: EntitySummary
Expand Down Expand Up @@ -49,7 +49,7 @@ export function IocRow({ ioc, onOpen }: IocRowProps) {
)}
</div>
<div className="font-mono text-[11px] text-muted-foreground">
{ioc.lastSeen ? relativeTime(ioc.lastSeen) : '—'}
{ioc.lastSeen ? absTimestamp(ioc.lastSeen) : '—'}
</div>
<div className="flex justify-end opacity-0 group-hover:opacity-100">
<button
Expand Down
30 changes: 26 additions & 4 deletions frontend/src/features/threat-intel/components/LookupBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,53 @@ import { Crosshair, Search } from 'lucide-react'
import { Button } from '@/shared/components/ui/button'
import { Input } from '@/shared/components/ui/input'

const ENTITY_TYPES = ['ip', 'domain', 'hostname', 'url', 'md5', 'sha1', 'sha256', 'sha3-256', 'cve', 'email-address', 'threat', 'malware']

interface LookupBarProps {
onSearch: (query: string) => void
onLookup: (input: { type: string; value: string }) => void
isPending?: boolean
isLookupPending?: boolean
}

export function LookupBar({ onSearch, isPending }: LookupBarProps) {
export function LookupBar({ onSearch, onLookup, isPending, isLookupPending }: LookupBarProps) {
const { t } = useTranslation()
const [q, setQ] = useState('')
const [selectedType, setSelectedType] = useState('any')

const handleSubmit = () => {
const trimmed = q.trim()
if (trimmed) onSearch(trimmed)
if (!trimmed) return
if (selectedType === 'any') {
onSearch(trimmed)
} else {
onLookup({ type: selectedType, value: trimmed })
}
}

const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') handleSubmit()
}

const busy = isPending || isLookupPending

return (
<div className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center gap-2 text-[11px] uppercase tracking-wider text-muted-foreground">
<Crosshair size={12} className="text-fuchsia-500" />
{t('threatIntel.lookup.title')}
</div>
<div className="mt-2 flex items-center gap-2">
<select
value={selectedType}
onChange={(e) => setSelectedType(e.target.value)}
className="h-10 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="any">Any type</option>
{ENTITY_TYPES.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
<div className="relative flex-1">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
Expand All @@ -39,9 +61,9 @@ export function LookupBar({ onSearch, isPending }: LookupBarProps) {
className="h-10 pl-9 font-mono text-sm"
/>
</div>
<Button onClick={handleSubmit} disabled={isPending}>
<Button onClick={handleSubmit} disabled={busy}>
<Crosshair size={13} className="mr-1.5" />
{isPending ? t('threatIntel.lookup.busy') : t('threatIntel.lookup.button')}
{busy ? t('threatIntel.lookup.busy') : selectedType === 'any' ? t('threatIntel.lookup.button') : 'Lookup'}
</Button>
</div>
</div>
Expand Down
Loading
Loading