diff --git a/frontend/src/features/threat-intel/components/FiltersPanel.tsx b/frontend/src/features/threat-intel/components/FiltersPanel.tsx
new file mode 100644
index 000000000..19ef6d581
--- /dev/null
+++ b/frontend/src/features/threat-intel/components/FiltersPanel.tsx
@@ -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 (
+
+
+
+
+ Filters
+ {active > 0 && (
+
+ {active}
+
+ )}
+
+
+
+
+ {onClose && (
+
+ )}
+
+
+
+
+
Types
+
+ {ALL_TYPES.map((t) => (
+
+ ))}
+
+
+
+
+
+
+
+
Tags include
+
onChange({ ...value, tagsInclude: parseTags(e.target.value) })}
+ className="h-8 text-sm"
+ />
+
+
+
Tags exclude
+
onChange({ ...value, tagsExclude: parseTags(e.target.value) })}
+ className="h-8 text-sm"
+ />
+
+
+
+
+
Date range
+
+
+
+
setDatePickerOpen(false)}
+ onConfirm={(next) => {
+ onChange({
+ ...value,
+ dateFrom: next.from ? next.from.toISOString() : null,
+ dateTo: next.to ? next.to.toISOString() : null,
+ })
+ setDatePickerOpen(false)
+ }}
+ />
+
+ )
+}
diff --git a/frontend/src/features/threat-intel/components/IocRow.tsx b/frontend/src/features/threat-intel/components/IocRow.tsx
index 57a5b9f22..e3cfc4108 100644
--- a/frontend/src/features/threat-intel/components/IocRow.tsx
+++ b/frontend/src/features/threat-intel/components/IocRow.tsx
@@ -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
@@ -49,7 +49,7 @@ export function IocRow({ ioc, onOpen }: IocRowProps) {
)}
- {ioc.lastSeen ? relativeTime(ioc.lastSeen) : '—'}
+ {ioc.lastSeen ? absTimestamp(ioc.lastSeen) : '—'}
-
+
@@ -171,12 +275,26 @@ export function ThreatIntelPage() {
{tab === 'iocs' && (
<>
-
-
- {t('threatIntel.toolbar.last24h')}
-
-
-
+
+
+
+
+ setFiltersOpen((o) => !o)}
+ >
{t('threatIntel.toolbar.filters')}
@@ -191,12 +309,27 @@ export function ThreatIntelPage() {
+ {filtersOpen && tab === 'iocs' && (
+
+ {
+ setFilters(EMPTY_FILTERS)
+ handleFiltersApply({})
+ }}
+ onClose={() => setFiltersOpen(false)}
+ />
+
+ )}
+
{tab === 'iocs' && (
= {}
+ for (const f of frags) {
+ if (!f) continue
+ if (f.query?.must) must.push(...f.query.must)
+ if (f.query?.should) should.push(...f.query.should)
+ if (f.query?.must_not) mustNot.push(...f.query.must_not)
+ if (f.query?.filter) filter.push(...f.query.filter)
+ if (f.query?.minimum_should_match !== undefined) minShouldMatch = f.query.minimum_should_match
+ if (f.aggs) Object.assign(aggs, f.aggs)
+ }
+ const query: NonNullable = {}
+ if (must.length) query.must = must
+ if (should.length) {
+ query.should = should
+ query.minimum_should_match = minShouldMatch ?? 1
+ }
+ if (mustNot.length) query.must_not = mustNot
+ if (filter.length) query.filter = filter
+ return {
+ query: Object.keys(query).length ? query : undefined,
+ aggs: Object.keys(aggs).length ? aggs : undefined,
+ }
+}
diff --git a/frontend/src/features/threat-intel/services/csv.ts b/frontend/src/features/threat-intel/services/csv.ts
index bfaeecc7a..038b20dd3 100644
--- a/frontend/src/features/threat-intel/services/csv.ts
+++ b/frontend/src/features/threat-intel/services/csv.ts
@@ -1,5 +1,3 @@
-// ponytail: minimal CSV writer — no new dep. Quote-when-needed, escape internal quotes.
-
function csvCell(value: unknown): string {
const s = value == null ? '' : String(value)
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
diff --git a/frontend/src/features/threat-intel/services/threat-intel-http.service.ts b/frontend/src/features/threat-intel/services/threat-intel-http.service.ts
index 0991ffbc4..550f7580c 100644
--- a/frontend/src/features/threat-intel/services/threat-intel-http.service.ts
+++ b/frontend/src/features/threat-intel/services/threat-intel-http.service.ts
@@ -1,7 +1,5 @@
import { createApiClient } from '@/shared/lib/api-client'
import type {
- EntitySearchRequest,
- EntitySearchResponse,
EntityDetail,
EntityRelation,
ThreatFeed,
@@ -11,6 +9,8 @@ import type {
TiResult,
AdvancedSearchRequest,
AdvancedSearchResponse,
+ EntityLookupRequest,
+ EntityLookupResponse,
} from '../domain/threat-intel.types'
import { isNotConfigured } from './ti-errors'
@@ -27,8 +27,6 @@ async function wrap(fn: () => Promise): Promise> {
}
export const threatIntelHttpService = {
- search: (body: EntitySearchRequest) =>
- wrap(() => api.post(`${BASE}/search`, body)),
searchAdvanced: (body: AdvancedSearchRequest, params?: { limit?: number; page?: number }) => {
const qs = new URLSearchParams()
if (params?.limit !== undefined) qs.set('limit', String(params.limit))
@@ -44,4 +42,6 @@ export const threatIntelHttpService = {
feeds: () => wrap(() => api.get(`${BASE}/feeds`)),
chat: (body: ChatRequest) => wrap(() => api.post(`${BASE}/ai/chat`, body)),
usage: () => wrap(() => api.get(`${BASE}/usage`)),
+ entityLookup: (body: EntityLookupRequest) =>
+ wrap(() => api.post(`${BASE}/entity/lookup`, body)),
}
diff --git a/frontend/src/features/threat-intel/services/ti-errors.ts b/frontend/src/features/threat-intel/services/ti-errors.ts
index eb6639c59..253482152 100644
--- a/frontend/src/features/threat-intel/services/ti-errors.ts
+++ b/frontend/src/features/threat-intel/services/ti-errors.ts
@@ -4,6 +4,10 @@ export function isNotConfigured(e: unknown): boolean {
return e instanceof ApiError && e.status === 503 && /not configured/i.test(e.message ?? '')
}
+export function isNotFound(e: unknown): boolean {
+ return e instanceof ApiError && e.status === 404
+}
+
export function describeError(e: unknown): string {
if (e instanceof ApiError) {
if (e.status === 502) return 'Threat intel upstream unavailable — try again.'