From 43f465ee7d67587e062e7ee64639c21a6b1a55a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Fri, 10 Jul 2026 14:28:47 -0600 Subject: [PATCH 1/7] fix[frontend](threat_intelligence): added threatintelligense page connection --- .../threat-intel/components/ActorCard.tsx | 63 + .../threat-intel/components/ActorDrawer.tsx | 158 +++ .../threat-intel/components/ActorsList.tsx | 43 + .../threat-intel/components/FeedRow.tsx | 57 + .../threat-intel/components/FeedsList.tsx | 66 + .../components/IntelInsightsCard.tsx | 29 + .../threat-intel/components/IocDrawer.tsx | 38 + .../threat-intel/components/IocDrawerBody.tsx | 204 +++ .../components/IocDrawerLoading.tsx | 22 + .../threat-intel/components/IocRow.tsx | 62 + .../threat-intel/components/IocTable.tsx | 38 + .../features/threat-intel/components/KV.tsx | 15 + .../threat-intel/components/LookupBar.tsx | 79 ++ .../components/MatchOverviewCard.tsx | 75 ++ .../threat-intel/components/Metric.tsx | 13 + .../components/NotConfiguredState.tsx | 17 + .../threat-intel/components/Section.tsx | 15 + .../features/threat-intel/components/Stat.tsx | 16 + .../threat-intel/components/TabsRow.tsx | 47 + .../components/ThreatIntelHeader.tsx | 27 + .../components/utils/severity-style.ts | 59 + .../components/utils/time-format.ts | 21 + .../threat-intel/domain/threat-intel.types.ts | 142 +++ .../threat-intel/hooks/use-ti-chat.ts | 12 + .../hooks/use-ti-config-status.ts | 10 + .../hooks/use-ti-entity-relations.ts | 11 + .../threat-intel/hooks/use-ti-entity.ts | 11 + .../threat-intel/hooks/use-ti-feeds.ts | 10 + .../threat-intel/hooks/use-ti-search.ts | 12 + .../threat-intel/hooks/use-ti-usage.ts | 10 + .../threat-intel/pages/ThreatIntelPage.tsx | 1125 +---------------- .../services/threat-intel-http.service.ts | 37 + .../threat-intel/services/ti-errors.ts | 14 + 33 files changed, 1469 insertions(+), 1089 deletions(-) create mode 100644 frontend/src/features/threat-intel/components/ActorCard.tsx create mode 100644 frontend/src/features/threat-intel/components/ActorDrawer.tsx create mode 100644 frontend/src/features/threat-intel/components/ActorsList.tsx create mode 100644 frontend/src/features/threat-intel/components/FeedRow.tsx create mode 100644 frontend/src/features/threat-intel/components/FeedsList.tsx create mode 100644 frontend/src/features/threat-intel/components/IntelInsightsCard.tsx create mode 100644 frontend/src/features/threat-intel/components/IocDrawer.tsx create mode 100644 frontend/src/features/threat-intel/components/IocDrawerBody.tsx create mode 100644 frontend/src/features/threat-intel/components/IocDrawerLoading.tsx create mode 100644 frontend/src/features/threat-intel/components/IocRow.tsx create mode 100644 frontend/src/features/threat-intel/components/IocTable.tsx create mode 100644 frontend/src/features/threat-intel/components/KV.tsx create mode 100644 frontend/src/features/threat-intel/components/LookupBar.tsx create mode 100644 frontend/src/features/threat-intel/components/MatchOverviewCard.tsx create mode 100644 frontend/src/features/threat-intel/components/Metric.tsx create mode 100644 frontend/src/features/threat-intel/components/NotConfiguredState.tsx create mode 100644 frontend/src/features/threat-intel/components/Section.tsx create mode 100644 frontend/src/features/threat-intel/components/Stat.tsx create mode 100644 frontend/src/features/threat-intel/components/TabsRow.tsx create mode 100644 frontend/src/features/threat-intel/components/ThreatIntelHeader.tsx create mode 100644 frontend/src/features/threat-intel/components/utils/severity-style.ts create mode 100644 frontend/src/features/threat-intel/components/utils/time-format.ts create mode 100644 frontend/src/features/threat-intel/domain/threat-intel.types.ts create mode 100644 frontend/src/features/threat-intel/hooks/use-ti-chat.ts create mode 100644 frontend/src/features/threat-intel/hooks/use-ti-config-status.ts create mode 100644 frontend/src/features/threat-intel/hooks/use-ti-entity-relations.ts create mode 100644 frontend/src/features/threat-intel/hooks/use-ti-entity.ts create mode 100644 frontend/src/features/threat-intel/hooks/use-ti-feeds.ts create mode 100644 frontend/src/features/threat-intel/hooks/use-ti-search.ts create mode 100644 frontend/src/features/threat-intel/hooks/use-ti-usage.ts create mode 100644 frontend/src/features/threat-intel/services/threat-intel-http.service.ts create mode 100644 frontend/src/features/threat-intel/services/ti-errors.ts diff --git a/frontend/src/features/threat-intel/components/ActorCard.tsx b/frontend/src/features/threat-intel/components/ActorCard.tsx new file mode 100644 index 000000000..4c58acb43 --- /dev/null +++ b/frontend/src/features/threat-intel/components/ActorCard.tsx @@ -0,0 +1,63 @@ +import { UserX } from 'lucide-react' +import { cn } from '@/shared/lib/utils' +import { searchItemValue, type EntitySummary } from '../domain/threat-intel.types' +import { REPUTATION_STYLE, reputationLabel, reputationTone } from './utils/severity-style' +import { Stat } from './Stat' +import { relativeTime } from './utils/time-format' + +interface ActorCardProps { + actor: EntitySummary + onOpen: (id: string) => void +} + +export function ActorCard({ actor, onOpen }: ActorCardProps) { + const tone = reputationTone(actor.reputation) + const rep = REPUTATION_STYLE[tone] + const dangerous = tone === 'danger' + + return ( + + ) +} diff --git a/frontend/src/features/threat-intel/components/ActorDrawer.tsx b/frontend/src/features/threat-intel/components/ActorDrawer.tsx new file mode 100644 index 000000000..4aceb3c3f --- /dev/null +++ b/frontend/src/features/threat-intel/components/ActorDrawer.tsx @@ -0,0 +1,158 @@ +import { Crosshair, ExternalLink, Search, UserX, X } from 'lucide-react' +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/components/ui/button' +import { useTiEntity } from '../hooks/use-ti-entity' +import { REPUTATION_STYLE, reputationTone, typeMeta } from './utils/severity-style' +import { absTimestamp } from './utils/time-format' +import { Section } from './Section' +import { Stat } from './Stat' + +interface ActorDrawerProps { + id: string | null + onClose: () => void +} + +export function ActorDrawer({ id, onClose }: ActorDrawerProps) { + const { data, isLoading } = useTiEntity(id) + + if (!id) return null + if (data?.kind === 'not-configured') return null + if (isLoading || !data || data.kind !== 'ok') { + return ( +
+
e.stopPropagation()} + > +
+
+
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+
+
+ ) + } + + const a = data.value.attributes + const tone = reputationTone(a.reputation_score) + const rep = REPUTATION_STYLE[tone] + const dangerous = tone === 'danger' + const associations = data.value.latest_associations ?? [] + + return ( +
+
e.stopPropagation()} + > +
+
+
+
+ +
+
+

{a.value}

+ {a.tags.length > 0 && ( +
+ {a.tags.join(', ')} +
+ )} +
+ + {a.reputation} + + Accuracy: {a.accuracy} +
+
+
+ +
+ +
+ + + +
+
+ +
+
+ {a.description && ( +
+

{a.description}

+
+ )} + +
+
+ + + +
+
+ + {associations.length > 0 && ( +
+
    + {associations.slice(0, 10).map((r) => { + const rt = typeMeta(r.type) + const RIcon = rt.icon + return ( +
  • +
    + + + {rt.label} + + {r.value} +
    + + {r.reputation} + +
  • + ) + })} +
+
+ )} +
+
+
+
+ ) +} diff --git a/frontend/src/features/threat-intel/components/ActorsList.tsx b/frontend/src/features/threat-intel/components/ActorsList.tsx new file mode 100644 index 000000000..79dc53626 --- /dev/null +++ b/frontend/src/features/threat-intel/components/ActorsList.tsx @@ -0,0 +1,43 @@ +import type { EntitySummary } from '../domain/threat-intel.types' +import { ActorCard } from './ActorCard' + +interface ActorsListProps { + actors: EntitySummary[] + onOpen: (id: string) => void + isLoading?: boolean +} + +export function ActorsList({ actors, onOpen, isLoading }: ActorsListProps) { + if (isLoading) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ ) + } + + if (actors.length === 0) { + return ( +
+ No actors found. +
+ ) + } + + return ( +
+ {actors.map((actor) => ( + onOpen(actor.id)} + /> + ))} +
+ ) +} diff --git a/frontend/src/features/threat-intel/components/FeedRow.tsx b/frontend/src/features/threat-intel/components/FeedRow.tsx new file mode 100644 index 000000000..6e1747641 --- /dev/null +++ b/frontend/src/features/threat-intel/components/FeedRow.tsx @@ -0,0 +1,57 @@ +import { MoreHorizontal } from 'lucide-react' +import { cn } from '@/shared/lib/utils' +import type { ThreatFeed } from '../domain/threat-intel.types' +import { FEED_STATUS_META, FEED_KIND_TONE } from '../components/utils/severity-style' +import { relativeTime } from '../components/utils/time-format' + +interface FeedRowProps { + feed: ThreatFeed +} + +const FEED_COLS = '12px 1fr 110px 110px 100px 110px 36px' + +export function FeedRow({ feed }: FeedRowProps) { + const st = FEED_STATUS_META[feed.status] + + return ( +
+ +
+
{feed.name}
+
+ syncs every{' '} + {feed.syncIntervalMin < 60 + ? `${feed.syncIntervalMin}m` + : feed.syncIntervalMin < 1440 + ? `${Math.round(feed.syncIntervalMin / 60)}h` + : `${Math.round(feed.syncIntervalMin / 1440)}d`} +
+
+
+ {feed.kind} +
+
+ {feed.itemsTotal.toLocaleString()} +
+
0 ? 'text-emerald-500' : 'text-muted-foreground' + )} + > + {feed.itemsAdded24h > 0 ? `+${feed.itemsAdded24h.toLocaleString()}` : '—'} +
+
+ {relativeTime(feed.lastSync)} +
+
+ +
+
+ ) +} diff --git a/frontend/src/features/threat-intel/components/FeedsList.tsx b/frontend/src/features/threat-intel/components/FeedsList.tsx new file mode 100644 index 000000000..0ecce75e2 --- /dev/null +++ b/frontend/src/features/threat-intel/components/FeedsList.tsx @@ -0,0 +1,66 @@ +import { useTiFeeds } from '../hooks/use-ti-feeds' +import { FeedRow } from './FeedRow' + +const FEED_COLS = '12px 1fr 110px 110px 100px 110px 36px' + +export function FeedsList() { + const { data, isLoading } = useTiFeeds() + + // Handle TiResult union + if (data?.kind === 'not-configured') return null + + if (isLoading) { + return ( +
+
+
+
Feed
+
Kind
+
Total
+
+24h
+
Last sync
+
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) + } + + const feeds = data?.kind === 'ok' ? data.value : [] + + if (feeds.length === 0) { + return ( +
+ No feeds configured. +
+ ) + } + + return ( +
+
+
+
Feed
+
Kind
+
Total
+
+24h
+
Last sync
+
+
+ {feeds.map((feed) => ( + + ))} +
+ ) +} diff --git a/frontend/src/features/threat-intel/components/IntelInsightsCard.tsx b/frontend/src/features/threat-intel/components/IntelInsightsCard.tsx new file mode 100644 index 000000000..807773ae7 --- /dev/null +++ b/frontend/src/features/threat-intel/components/IntelInsightsCard.tsx @@ -0,0 +1,29 @@ +import { Sparkles } from 'lucide-react' + +export function IntelInsightsCard() { + // ponytail: static copy — swap to useTiChat prompt when product spec lands + return ( +
+
+ +

SOC AI insights

+
+ +
+ 3 adversaries in your env attributed to{' '} + Scattered Spider — campaign appears active.{' '} + +
+ +
+ 2 KEV CVEs applicable to your stack — no + patches deployed yet. +
+ +
+ 1 feed errored · Recorded Future auth + token expired 12h ago. +
+
+ ) +} diff --git a/frontend/src/features/threat-intel/components/IocDrawer.tsx b/frontend/src/features/threat-intel/components/IocDrawer.tsx new file mode 100644 index 000000000..7ae8b63b2 --- /dev/null +++ b/frontend/src/features/threat-intel/components/IocDrawer.tsx @@ -0,0 +1,38 @@ +import { useTiEntity } from '../hooks/use-ti-entity' +import { useTiEntityRelations } from '../hooks/use-ti-entity-relations' +import { IocDrawerBody } from './IocDrawerBody' +import { IocDrawerLoading } from './IocDrawerLoading' + +interface IocDrawerProps { + id: string | null + onClose: () => void +} + +export function IocDrawer({ id, onClose }: IocDrawerProps) { + const { data: entityData, isLoading: entityLoading } = useTiEntity(id ?? '') + const { data: relationsData } = useTiEntityRelations(id ?? '') + + if (!id) return null + if (entityData?.kind === 'not-configured') return null + + const detail = entityData?.kind === 'ok' ? entityData.value : null + const relations = relationsData?.kind === 'ok' ? relationsData.value : [] + + return ( +
+
e.stopPropagation()} + > + {entityLoading || !detail ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/frontend/src/features/threat-intel/components/IocDrawerBody.tsx b/frontend/src/features/threat-intel/components/IocDrawerBody.tsx new file mode 100644 index 000000000..01373c12e --- /dev/null +++ b/frontend/src/features/threat-intel/components/IocDrawerBody.tsx @@ -0,0 +1,204 @@ +import { ExternalLink, Globe2, MapPin, MoreHorizontal, Search, Shield, Sparkles, X } from 'lucide-react' +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/components/ui/button' +import type { EntityDetail, EntityRelation } from '../domain/threat-intel.types' +import { REPUTATION_STYLE, reputationTone, typeMeta } from './utils/severity-style' +import { absTimestamp, relativeTime } from './utils/time-format' +import { Section } from './Section' +import { KV } from './KV' +import { Metric } from './Metric' + +interface IocDrawerBodyProps { + detail: EntityDetail + relations: EntityRelation[] + onClose: () => void +} + +export function IocDrawerBody({ detail, relations, onClose }: IocDrawerBodyProps) { + const a = detail.attributes + const tone = reputationTone(a.reputation_score) + const rep = REPUTATION_STYLE[tone] + const t = typeMeta(a.type) + const TIcon = t.icon + const associations = detail.latest_associations ?? [] + const merged = relations.length > 0 ? relations : associations + const meta = detail.metadata + const hasMeta = !!(meta && (meta.asn || meta.aso || meta.city || meta.country)) + + return ( + <> +
+
+
+
+ + {t.label} + · + {a.reputation} + · + Accuracy: {a.accuracy} +
+

+ {a.value} +

+ {a.description && ( +

{a.description}

+ )} + {a.tags.length > 0 && ( +
+ {a.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ +
+ +
+ + + + +
+
+ +
+
+
+
+
+ + {absTimestamp(a.first_seen)} + + + {absTimestamp(a.last_seen)} + +
+
+ + {hasMeta && ( +
+
+ {meta.country && {meta.country}} + {meta.city && {meta.city}} + {meta.aso && {meta.aso}} + {meta.asn > 0 && {meta.asn}} +
+
+ )} + + {merged.length > 0 && ( +
+
    + {merged.slice(0, 12).map((r) => { + const rt = typeMeta(r.type) + const RIcon = rt.icon + const rTone = reputationTone(r.reputation_score) + return ( +
  • +
    + + + {rt.label} + + {r.value} +
    + + {r.reputation} + +
  • + ) + })} + {merged.length > 12 && ( +
  • + +{merged.length - 12} more +
  • + )} +
+
+ )} + + {detail.geolocations?.length > 0 && ( +
+
    + {detail.geolocations.slice(0, 6).map((g) => ( +
  • +
    + + {g.object} +
    +
    + + + {g.city} + {g.country ? `, ${g.country}` : ''} + +
    +
  • + ))} + {detail.geolocations.length > 6 && ( +
  • + +{detail.geolocations.length - 6} more +
  • + )} +
+
+ )} +
+ + +
+
+ + ) +} diff --git a/frontend/src/features/threat-intel/components/IocDrawerLoading.tsx b/frontend/src/features/threat-intel/components/IocDrawerLoading.tsx new file mode 100644 index 000000000..75e83aca8 --- /dev/null +++ b/frontend/src/features/threat-intel/components/IocDrawerLoading.tsx @@ -0,0 +1,22 @@ +import { X } from 'lucide-react' + +interface IocDrawerLoadingProps { + onClose: () => void +} + +export function IocDrawerLoading({ onClose }: IocDrawerLoadingProps) { + return ( + <> +
+ Loading… + +
+
+ + ) +} diff --git a/frontend/src/features/threat-intel/components/IocRow.tsx b/frontend/src/features/threat-intel/components/IocRow.tsx new file mode 100644 index 000000000..2e4803c97 --- /dev/null +++ b/frontend/src/features/threat-intel/components/IocRow.tsx @@ -0,0 +1,62 @@ +import { MoreHorizontal } from 'lucide-react' +import { cn } from '@/shared/lib/utils' +import { searchItemValue, type EntitySummary } from '../domain/threat-intel.types' +import { REPUTATION_STYLE, reputationLabel, reputationTone, typeMeta } from './utils/severity-style' +import { relativeTime } from './utils/time-format' + +interface IocRowProps { + ioc: EntitySummary + onOpen: (id: string) => void +} + +const IOC_COLS = '4px 90px 1fr 130px 1fr 110px 36px' + +export function IocRow({ ioc, onOpen }: IocRowProps) { + const tone = reputationTone(ioc.reputation) + const rep = REPUTATION_STYLE[tone] + const t = typeMeta(ioc.type) + const TIcon = t.icon + + return ( +
onOpen(ioc.id)} + className="group grid cursor-pointer items-center gap-3 border-b border-border/60 px-4 py-2.5 text-xs hover:bg-muted/40 last:border-b-0" + style={{ gridTemplateColumns: IOC_COLS }} + > + +
+ + + {t.label} + +
+
{searchItemValue(ioc)}
+
+ + {reputationLabel(ioc.reputation)} + +
+
+ {ioc.tags?.slice(0, 3).map((tag) => ( + + {tag} + + ))} + {ioc.tags && ioc.tags.length > 3 && ( + +{ioc.tags.length - 3} + )} +
+
+ {ioc.lastSeen ? relativeTime(ioc.lastSeen) : '—'} +
+
+ +
+
+ ) +} diff --git a/frontend/src/features/threat-intel/components/IocTable.tsx b/frontend/src/features/threat-intel/components/IocTable.tsx new file mode 100644 index 000000000..ea1e864a3 --- /dev/null +++ b/frontend/src/features/threat-intel/components/IocTable.tsx @@ -0,0 +1,38 @@ +import type { EntitySummary } from '../domain/threat-intel.types' +import { IocRow } from './IocRow' + +interface IocTableProps { + iocs: EntitySummary[] + onOpen: (id: string) => void + isLoading?: boolean +} + +const IOC_COLS = '4px 90px 1fr 130px 1fr 110px 36px' + +export function IocTable({ iocs, onOpen, isLoading }: IocTableProps) { + return ( +
+
+
+
Type
+
Indicator
+
Reputation
+
Tags
+
Last seen
+
+
+ {iocs.map((ioc) => ( + onOpen(ioc.id)} /> + ))} + {!isLoading && iocs.length === 0 && ( +
No IOCs match.
+ )} + {isLoading && ( +
Loading…
+ )} +
+ ) +} diff --git a/frontend/src/features/threat-intel/components/KV.tsx b/frontend/src/features/threat-intel/components/KV.tsx new file mode 100644 index 000000000..54f81cad5 --- /dev/null +++ b/frontend/src/features/threat-intel/components/KV.tsx @@ -0,0 +1,15 @@ +import React from 'react' + +interface KVProps { + k: string + children: React.ReactNode +} + +export function KV({ k, children }: KVProps) { + return ( + <> +
{k}
+
{children}
+ + ) +} diff --git a/frontend/src/features/threat-intel/components/LookupBar.tsx b/frontend/src/features/threat-intel/components/LookupBar.tsx new file mode 100644 index 000000000..889137525 --- /dev/null +++ b/frontend/src/features/threat-intel/components/LookupBar.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from 'react' +import { Crosshair, Search } from 'lucide-react' +import { Button } from '@/shared/components/ui/button' +import { Input } from '@/shared/components/ui/input' +import { useTiSearch } from '../hooks/use-ti-search' +import type { EntitySearchResponse } from '../domain/threat-intel.types' + +interface LookupBarProps { + onResults: (data: EntitySearchResponse | null) => void +} + +export function LookupBar({ onResults }: LookupBarProps) { + const [q, setQ] = useState('') + const { mutate, isPending } = useTiSearch() + + + const handleSubmit = () => { + if (q.trim()) { + mutate( + { query: q }, + { + onSuccess: (data) => { + if (data?.kind === 'not-configured') { + onResults(null) + } else if (data?.kind === 'ok') { + onResults(data.value) + } + }, + } + ) + } + } + + useEffect(()=>{ + mutate( + { query: '*' }, + { + onSuccess: (data) => { + if (data?.kind === 'not-configured') { + onResults(null) + } else if (data?.kind === 'ok') { + onResults(data.value) + } + }, + } + ) + },[]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSubmit() + } + } + + return ( +
+
+ + Lookup any indicator +
+
+
+ + setQ(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Paste an IP, domain, hash, URL, or CVE — we'll check feeds and your environment" + className="h-10 pl-9 font-mono text-sm" + /> +
+ +
+
+ ) +} diff --git a/frontend/src/features/threat-intel/components/MatchOverviewCard.tsx b/frontend/src/features/threat-intel/components/MatchOverviewCard.tsx new file mode 100644 index 000000000..c4bbf86eb --- /dev/null +++ b/frontend/src/features/threat-intel/components/MatchOverviewCard.tsx @@ -0,0 +1,75 @@ +import { useMemo } from 'react' + +export interface MatchOverviewCardProps { + total?: number +} + +export function MatchOverviewCard({ total = 0 }: MatchOverviewCardProps) { + // ponytail: hardcoded sparkline until CM exposes a timeseries endpoint + const buckets = 48 + const data = useMemo( + () => + Array.from({ length: buckets }, (_, i) => + Math.max( + 0, + Math.round(Math.abs(Math.sin(i / 5)) * 18 + Math.abs(Math.sin(i / 11)) * 8 + (i === 38 ? 12 : 0) + 3) + ) + ), + [] + ) + const w = 1000 + const h = 100 + const max = Math.max(...data) * 1.15 + const xs = data.map((_, i) => (i * w) / (data.length - 1)) + const ys = data.map((v) => h - (v / max) * h) + const linePath = data.reduce((acc, _, i) => { + if (i === 0) return `M ${xs[i]} ${ys[i]}` + const prevX = xs[i - 1] + const prevY = ys[i - 1] + const cx1 = prevX + (xs[i] - prevX) / 2 + const cx2 = xs[i] - (xs[i] - prevX) / 2 + return `${acc} C ${cx1} ${prevY}, ${cx2} ${ys[i]}, ${xs[i]} ${ys[i]}` + }, '') + const areaPath = `${linePath} L ${xs[xs.length - 1]} ${h} L ${xs[0]} ${h} Z` + + return ( +
+
+
+
+ IOC matches · last 24 hours +
+
+ {total.toLocaleString()} + total indicators +
+
+
+ + + + + + + + + + + + +
+ 24h ago + 12h ago + now +
+
+ ) +} diff --git a/frontend/src/features/threat-intel/components/Metric.tsx b/frontend/src/features/threat-intel/components/Metric.tsx new file mode 100644 index 000000000..47cc36503 --- /dev/null +++ b/frontend/src/features/threat-intel/components/Metric.tsx @@ -0,0 +1,13 @@ +interface MetricProps { + label: string + value: string +} + +export function Metric({ label, value }: MetricProps) { + return ( +
+ {label} + {value} +
+ ) +} diff --git a/frontend/src/features/threat-intel/components/NotConfiguredState.tsx b/frontend/src/features/threat-intel/components/NotConfiguredState.tsx new file mode 100644 index 000000000..652aa54f1 --- /dev/null +++ b/frontend/src/features/threat-intel/components/NotConfiguredState.tsx @@ -0,0 +1,17 @@ +import { ShieldOff } from 'lucide-react' + +export function NotConfiguredState() { + return ( +
+
+
+ +
+

Threat Intelligence isn't configured

+

+ Contact your administrator to connect an upstream Cyber Mantra / ThreatWinds instance for this environment. +

+
+
+ ) +} diff --git a/frontend/src/features/threat-intel/components/Section.tsx b/frontend/src/features/threat-intel/components/Section.tsx new file mode 100644 index 000000000..f4b54721b --- /dev/null +++ b/frontend/src/features/threat-intel/components/Section.tsx @@ -0,0 +1,15 @@ +import React from 'react' + +interface SectionProps { + title: string + children: React.ReactNode +} + +export function Section({ title, children }: SectionProps) { + return ( +
+

{title}

+ {children} +
+ ) +} diff --git a/frontend/src/features/threat-intel/components/Stat.tsx b/frontend/src/features/threat-intel/components/Stat.tsx new file mode 100644 index 000000000..49a960b83 --- /dev/null +++ b/frontend/src/features/threat-intel/components/Stat.tsx @@ -0,0 +1,16 @@ +import { cn } from '@/shared/lib/utils' + +interface StatProps { + label: string + value: string + tone?: string +} + +export function Stat({ label, value, tone }: StatProps) { + return ( +
+
{label}
+
{value}
+
+ ) +} diff --git a/frontend/src/features/threat-intel/components/TabsRow.tsx b/frontend/src/features/threat-intel/components/TabsRow.tsx new file mode 100644 index 000000000..7801b3364 --- /dev/null +++ b/frontend/src/features/threat-intel/components/TabsRow.tsx @@ -0,0 +1,47 @@ +import { cn } from '@/shared/lib/utils' + +export type TabKey = 'iocs' | 'actors' | 'feeds' + +export interface TabsRowProps { + active: TabKey + onChange: (t: TabKey) => void + counts?: { iocs?: number; actors?: number; feeds?: number } +} + +const TABS: { id: TabKey; label: string }[] = [ + { id: 'iocs', label: 'IOCs' }, + { id: 'actors', label: 'Threat actors' }, + { id: 'feeds', label: 'Feeds' }, +] + +export function TabsRow({ active, onChange, counts }: TabsRowProps) { + return ( +
+ {TABS.map((t) => { + const tabActive = active === t.id + const count = counts?.[t.id] ?? 0 + return ( + + ) + })} +
+ ) +} diff --git a/frontend/src/features/threat-intel/components/ThreatIntelHeader.tsx b/frontend/src/features/threat-intel/components/ThreatIntelHeader.tsx new file mode 100644 index 000000000..4738d9a41 --- /dev/null +++ b/frontend/src/features/threat-intel/components/ThreatIntelHeader.tsx @@ -0,0 +1,27 @@ +import { Download, FileText } from 'lucide-react' +import { Button } from '@/shared/components/ui/button' + +export interface ThreatIntelHeaderProps { + matchedCount?: number + onRefresh?: () => void +} + +export function ThreatIntelHeader({ matchedCount }: ThreatIntelHeaderProps) { + return ( +
+
+ {matchedCount?.toLocaleString() || 0} matched in your env +
+
+ + +
+
+ ) +} diff --git a/frontend/src/features/threat-intel/components/utils/severity-style.ts b/frontend/src/features/threat-intel/components/utils/severity-style.ts new file mode 100644 index 000000000..098329078 --- /dev/null +++ b/frontend/src/features/threat-intel/components/utils/severity-style.ts @@ -0,0 +1,59 @@ +import { type LucideIcon, Bug, Fingerprint, Globe2, Hash, LinkIcon, Network, Skull } from 'lucide-react' +import type { EntityType, FeedKind, FeedStatus } from '../../domain/threat-intel.types' + +export type ReputationTone = 'danger' | 'warning' | 'neutral' | 'unknown' + +export const REPUTATION_STYLE: Record = { + danger: { bar: 'bg-red-500', tone: 'text-red-500' }, + warning: { bar: 'bg-amber-500', tone: 'text-amber-500' }, + neutral: { bar: 'bg-sky-500', tone: 'text-sky-500' }, + unknown: { bar: 'bg-muted', tone: 'text-muted-foreground' }, +} + +// ponytail: CM reputation_score seen values are 0 (Indefinable) and -3 (Alarming). +// Buckets: <0 danger, 0 unknown-neutral, positive safe. Tighten when CM docs land. +export function reputationTone(score: number): ReputationTone { + if (score <= -2) return 'danger' + if (score < 0) return 'warning' + if (score === 0) return 'unknown' + return 'neutral' +} + +// Search items only give a numeric reputation. Detail responses give both the score +// and CM's string. This helper is used when only the score is available. +export function reputationLabel(score: number): string { + if (score <= -3) return 'Alarming' + if (score <= -1) return 'Suspicious' + if (score === 0) return 'Indefinable' + if (score === 1) return 'Fair' + return 'Trustworthy' +} + +const TYPE_FALLBACK = { icon: Fingerprint, label: 'Entity', tone: 'text-muted-foreground' } as const + +const TYPE_TABLE: Partial> = { + ip: { icon: Network, label: 'IP', tone: 'text-sky-500' }, + hostname: { icon: LinkIcon, label: 'Host', tone: 'text-amber-500' }, + domain: { icon: Globe2, label: 'Domain', tone: 'text-violet-500' }, + url: { icon: LinkIcon, label: 'URL', tone: 'text-amber-500' }, + hash: { icon: Hash, label: 'Hash', tone: 'text-fuchsia-500' }, + cve: { icon: Bug, label: 'CVE', tone: 'text-red-500' }, + threat: { icon: Skull, label: 'Threat', tone: 'text-red-500' }, +} + +export function typeMeta(type: EntityType): { icon: LucideIcon; label: string; tone: string } { + return TYPE_TABLE[type] ?? TYPE_FALLBACK +} + +export const FEED_KIND_TONE: Record = { + commercial: 'text-violet-500', + open: 'text-sky-500', + internal: 'text-emerald-500', +} + +export const FEED_STATUS_META: Record = { + healthy: { dot: 'bg-emerald-500', label: 'Healthy' }, + stale: { dot: 'bg-amber-500', label: 'Stale' }, + error: { dot: 'bg-red-500 animate-pulse', label: 'Error' }, + paused: { dot: 'bg-muted-foreground', label: 'Paused' }, +} diff --git a/frontend/src/features/threat-intel/components/utils/time-format.ts b/frontend/src/features/threat-intel/components/utils/time-format.ts new file mode 100644 index 000000000..f55a9c1d8 --- /dev/null +++ b/frontend/src/features/threat-intel/components/utils/time-format.ts @@ -0,0 +1,21 @@ +const NOW = new Date('2026-05-01T16:42:00Z').getTime() + +export function relativeTime(iso: string) { + const diff = NOW - new Date(iso).getTime() + const m = Math.round(diff / 60_000) + if (m < 1) return 'just now' + if (m < 60) return `${m}m ago` + const h = Math.round(m / 60) + if (h < 24) return `${h}h ago` + return `${Math.round(h / 24)}d ago` +} + +export function absTimestamp(iso: string) { + return new Date(iso).toLocaleString(undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} diff --git a/frontend/src/features/threat-intel/domain/threat-intel.types.ts b/frontend/src/features/threat-intel/domain/threat-intel.types.ts new file mode 100644 index 000000000..2d3248c9b --- /dev/null +++ b/frontend/src/features/threat-intel/domain/threat-intel.types.ts @@ -0,0 +1,142 @@ +// CM/ThreatWinds analytics response shapes. Wire the proxy responses through as-is. + +export type EntityType = + | 'ip' + | 'hostname' + | 'domain' + | 'url' + | 'hash' + | 'cve' + | 'threat' + | (string & {}) + +export interface EntityAttributes { + id: string + type: EntityType + value: string + label: string + description?: string + tags: string[] + first_seen: string + last_seen: string + reputation_score: number + reputation: string + best_reputation_score: number + best_reputation: string + worst_reputation_score: number + worst_reputation: string + accuracy_score: number + accuracy: string +} + +export interface EntityMetadata { + asn: number + aso: string + city: string + country: string + latitude: number + longitude: number +} + +export interface EntityGeolocation { + object: string + country: string + city: string + latitude: number + longitude: number + accuracy_radius: number + asn: number + aso: string +} + +export interface EntityDetail { + attributes: EntityAttributes + metadata: EntityMetadata + extended_metadata: unknown[] + latest_associations: EntityAttributes[] + geolocations: EntityGeolocation[] +} + +// Search results are a different CM shape than entity detail: camelCase, no label / +// description, value nested under `attributes[]`, reputation is a number. +export interface EntitySearchItem { + '@timestamp': string + id: string + type: EntityType + attributes: Record + tags: string[] + lastSeen: string + reputation: number + bestReputation: number + worstReputation: number + accuracy: number + score: number + version: number + visibleBy: string[] + wellKnown: boolean + fields: unknown | null + sort: unknown[] +} + +export type EntitySummary = EntitySearchItem +// ponytail: relations endpoint shape unconfirmed — assumed EntityAttributes[] like +// latest_associations from detail. Narrow when a real response is observed. +export type EntityRelation = EntityAttributes + +// Search items nest the display value inside `attributes[]` (e.g. +// { type: 'hostname', attributes: { hostname: 'asas-asso.com' } }). Pick the +// first string value if the expected key isn't found. +export function searchItemValue(item: EntitySearchItem): string { + return item.attributes[item.type] ?? Object.values(item.attributes)[0] ?? '' +} + +export interface EntitySearchRequest { + query: string + types?: EntityType[] + limit?: number + offset?: number +} + +export interface EntitySearchResponse { + results: EntitySummary[] + total: number +} + +export type FeedKind = 'commercial' | 'open' | 'internal' +export type FeedStatus = 'healthy' | 'stale' | 'error' | 'paused' + +export interface ThreatFeed { + id: string + name: string + kind: FeedKind + status: FeedStatus + itemsTotal: number + itemsAdded24h: number + lastSync: string + syncIntervalMin: number +} + +export interface ChatMessage { + role: 'user' | 'assistant' | 'system' + content: string +} + +export interface ChatRequest { + messages: ChatMessage[] + model?: string +} + +export interface ChatResponse { + message: ChatMessage + usage?: { totalTokens: number } +} + +export interface UsageInfo { + used: number + quota: number + resetsAt?: string +} + +export type TiResult = + | { kind: 'ok'; value: T } + | { kind: 'not-configured' } diff --git a/frontend/src/features/threat-intel/hooks/use-ti-chat.ts b/frontend/src/features/threat-intel/hooks/use-ti-chat.ts new file mode 100644 index 000000000..c1574173e --- /dev/null +++ b/frontend/src/features/threat-intel/hooks/use-ti-chat.ts @@ -0,0 +1,12 @@ +import { useMutation } from '@tanstack/react-query' +import { toast } from 'sonner' +import { threatIntelHttpService } from '../services/threat-intel-http.service' +import { describeError, isNotConfigured } from '../services/ti-errors' +import type { ChatRequest } from '../domain/threat-intel.types' + +export function useTiChat() { + return useMutation({ + mutationFn: (req: ChatRequest) => threatIntelHttpService.chat(req), + onError: (e) => { if (!isNotConfigured(e)) toast.error(describeError(e)) }, + }) +} diff --git a/frontend/src/features/threat-intel/hooks/use-ti-config-status.ts b/frontend/src/features/threat-intel/hooks/use-ti-config-status.ts new file mode 100644 index 000000000..a4625c80a --- /dev/null +++ b/frontend/src/features/threat-intel/hooks/use-ti-config-status.ts @@ -0,0 +1,10 @@ +import { useTiUsage } from './use-ti-usage' + +export function useTiConfigStatus(): { isConfigured: boolean | undefined; isLoading: boolean } { + const q = useTiUsage() + if (q.isLoading) return { isConfigured: undefined, isLoading: true } + if (q.data?.kind === 'not-configured') return { isConfigured: false, isLoading: false } + if (q.data?.kind === 'ok') return { isConfigured: true, isLoading: false } + // request errored (non-503) — treat as configured but broken; the individual hooks handle their own errors + return { isConfigured: true, isLoading: false } +} diff --git a/frontend/src/features/threat-intel/hooks/use-ti-entity-relations.ts b/frontend/src/features/threat-intel/hooks/use-ti-entity-relations.ts new file mode 100644 index 000000000..0038db86c --- /dev/null +++ b/frontend/src/features/threat-intel/hooks/use-ti-entity-relations.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query' +import { threatIntelHttpService } from '../services/threat-intel-http.service' + +export function useTiEntityRelations(id: string | null | undefined) { + return useQuery({ + queryKey: ['ti', 'entity', id, 'relations'], + queryFn: () => threatIntelHttpService.relations(id as string), + enabled: !!id, + staleTime: 5 * 60_000, + }) +} diff --git a/frontend/src/features/threat-intel/hooks/use-ti-entity.ts b/frontend/src/features/threat-intel/hooks/use-ti-entity.ts new file mode 100644 index 000000000..59391adf4 --- /dev/null +++ b/frontend/src/features/threat-intel/hooks/use-ti-entity.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query' +import { threatIntelHttpService } from '../services/threat-intel-http.service' + +export function useTiEntity(id: string | null | undefined) { + return useQuery({ + queryKey: ['ti', 'entity', id], + queryFn: () => threatIntelHttpService.entity(id as string), + enabled: !!id, + staleTime: 5 * 60_000, + }) +} diff --git a/frontend/src/features/threat-intel/hooks/use-ti-feeds.ts b/frontend/src/features/threat-intel/hooks/use-ti-feeds.ts new file mode 100644 index 000000000..205275c1a --- /dev/null +++ b/frontend/src/features/threat-intel/hooks/use-ti-feeds.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query' +import { threatIntelHttpService } from '../services/threat-intel-http.service' + +export function useTiFeeds() { + return useQuery({ + queryKey: ['ti', 'feeds'], + queryFn: () => threatIntelHttpService.feeds(), + staleTime: 60_000, + }) +} diff --git a/frontend/src/features/threat-intel/hooks/use-ti-search.ts b/frontend/src/features/threat-intel/hooks/use-ti-search.ts new file mode 100644 index 000000000..0422409c7 --- /dev/null +++ b/frontend/src/features/threat-intel/hooks/use-ti-search.ts @@ -0,0 +1,12 @@ +import { useMutation } from '@tanstack/react-query' +import { toast } from 'sonner' +import { threatIntelHttpService } from '../services/threat-intel-http.service' +import { describeError, isNotConfigured } from '../services/ti-errors' +import type { EntitySearchRequest } from '../domain/threat-intel.types' + +export function useTiSearch() { + return useMutation({ + mutationFn: (req: EntitySearchRequest) => threatIntelHttpService.search(req), + onError: (e) => { if (!isNotConfigured(e)) toast.error(describeError(e)) }, + }) +} diff --git a/frontend/src/features/threat-intel/hooks/use-ti-usage.ts b/frontend/src/features/threat-intel/hooks/use-ti-usage.ts new file mode 100644 index 000000000..5740c7a32 --- /dev/null +++ b/frontend/src/features/threat-intel/hooks/use-ti-usage.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query' +import { threatIntelHttpService } from '../services/threat-intel-http.service' + +export function useTiUsage() { + return useQuery({ + queryKey: ['ti', 'usage'], + queryFn: () => threatIntelHttpService.usage(), + staleTime: 60_000, + }) +} diff --git a/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx b/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx index 6093169dd..a81ce7ddd 100644 --- a/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx +++ b/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx @@ -1,308 +1,43 @@ -import { useMemo, useState } from 'react' -import { - AlertTriangle, - Bug, - ChevronDown, - Clock, - Crosshair, - Download, - ExternalLink, - FileText, - Fingerprint, - Globe2, - Hash, - Link as LinkIcon, - ListFilter, - MoreHorizontal, - Network, - RefreshCw, - Search, - Shield, - Sparkles, - UserX, - X, - type LucideIcon, -} from 'lucide-react' -import { cn } from '@/shared/lib/utils' +import { useState } from 'react' +import { ChevronDown, Clock, ListFilter, RefreshCw, Search } from 'lucide-react' import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' - -/* ─── Types ────────────────────────────────────────────────────────────── */ - -type IocType = 'ipv4' | 'domain' | 'sha256' | 'md5' | 'url' | 'cve' -type Severity = 'critical' | 'high' | 'medium' | 'low' -type FeedKind = 'commercial' | 'open' | 'internal' -type FeedStatus = 'healthy' | 'stale' | 'error' | 'paused' - -interface IOC { - id: string - type: IocType - value: string - severity: Severity - feeds: string[] - matchesInEnv: number - firstSeenInEnv?: string - lastSeenInEnv?: string - tags: string[] - attributedActor?: string -} - -interface ThreatActor { - id: string - name: string - aliases: string[] - techniques: string[] - sectors: string[] - origin: string[] - matchedAdversaries: number - lastActivity: string - iocCount: number - description: string -} - -interface ThreatFeed { - id: string - name: string - kind: FeedKind - status: FeedStatus - itemsTotal: number - itemsAdded24h: number - lastSync: string - syncIntervalMin: number -} - -/* ─── Mock data ────────────────────────────────────────────────────────── */ - -const NOW = new Date('2026-05-01T16:42:00Z').getTime() -const min = (m: number) => new Date(NOW - m * 60_000).toISOString() -const hr = (h: number) => new Date(NOW - h * 3_600_000).toISOString() -const days = (d: number) => new Date(NOW - d * 86_400_000).toISOString() - -const IOCS: IOC[] = [ - { - id: 'ioc-1', - type: 'ipv4', - value: '198.51.100.14', - severity: 'critical', - feeds: ['AlienVault OTX', 'Mandiant', 'AbuseIPDB'], - matchesInEnv: 23, - firstSeenInEnv: hr(46), - lastSeenInEnv: min(4), - tags: ['C2', 'Scattered Spider', 'campaign-44'], - attributedActor: 'Scattered Spider', - }, - { - id: 'ioc-2', - type: 'sha256', - value: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', - severity: 'critical', - feeds: ['VirusTotal', 'Mandiant'], - matchesInEnv: 1, - firstSeenInEnv: min(62), - lastSeenInEnv: min(62), - tags: ['Mimikatz', 'credential-dumping'], - attributedActor: 'Scattered Spider', - }, - { - id: 'ioc-3', - type: 'domain', - value: 'malicious-update[.]com', - severity: 'high', - feeds: ['AbuseCH', 'AlienVault OTX'], - matchesInEnv: 8, - firstSeenInEnv: hr(2), - lastSeenInEnv: min(14), - tags: ['phishing', 'credential-harvest'], - }, - { - id: 'ioc-4', - type: 'ipv4', - value: '45.83.66.12', - severity: 'high', - feeds: ['Spamhaus', 'AlienVault OTX'], - matchesInEnv: 4, - firstSeenInEnv: hr(8), - lastSeenInEnv: min(74), - tags: ['exfiltration', 'bulletproof-hoster'], - }, - { - id: 'ioc-5', - type: 'cve', - value: 'CVE-2024-38077', - severity: 'critical', - feeds: ['NVD', 'CISA KEV'], - matchesInEnv: 0, - tags: ['RCE', 'Windows', 'KEV'], - }, - { - id: 'ioc-6', - type: 'ipv4', - value: '203.0.113.7', - severity: 'medium', - feeds: ['Internal blocklist'], - matchesInEnv: 312, - firstSeenInEnv: hr(2), - lastSeenInEnv: min(23), - tags: ['brute-force', 'recent-abuse'], - }, - { - id: 'ioc-7', - type: 'url', - value: 'hxxp://exfil[.]bad/upload', - severity: 'high', - feeds: ['Internal IOC db'], - matchesInEnv: 2, - firstSeenInEnv: hr(4), - lastSeenInEnv: hr(4), - tags: ['exfiltration'], - }, - { - id: 'ioc-8', - type: 'md5', - value: 'd41d8cd98f00b204e9800998ecf8427e', - severity: 'medium', - feeds: ['VirusTotal'], - matchesInEnv: 0, - tags: ['adware'], - }, - { - id: 'ioc-9', - type: 'domain', - value: 'cdn-update-service[.]net', - severity: 'low', - feeds: ['Spamhaus'], - matchesInEnv: 0, - tags: ['suspicious-tld'], - }, - { - id: 'ioc-10', - type: 'cve', - value: 'CVE-2024-21412', - severity: 'high', - feeds: ['NVD', 'CISA KEV'], - matchesInEnv: 0, - tags: ['SmartScreen-bypass', 'KEV'], - }, -] - -const ACTORS: ThreatActor[] = [ - { - id: 'actor-scattered-spider', - name: 'Scattered Spider', - aliases: ['UNC3944', 'Scatter Swine', 'Octo Tempest'], - techniques: ['T1558.003', 'T1059.001', 'T1071.001', 'T1003'], - sectors: ['Telecom', 'Hospitality', 'Tech', 'Finance'], - origin: ['Unknown'], - matchedAdversaries: 3, - lastActivity: min(4), - iocCount: 47, - description: - 'Financially motivated threat group known for SIM swapping, social engineering, and Kerberoasting attacks against cloud and on-prem infrastructure.', - }, - { - id: 'actor-fin7', - name: 'FIN7', - aliases: ['Carbon Spider', 'Sangria Tempest'], - techniques: ['T1566', 'T1059.001', 'T1027'], - sectors: ['Retail', 'Hospitality', 'Finance'], - origin: ['Russia (suspected)'], - matchedAdversaries: 1, - lastActivity: days(8), - iocCount: 312, - description: - 'Long-running cybercrime group targeting POS systems and financial data through targeted phishing.', - }, - { - id: 'actor-apt29', - name: 'APT29', - aliases: ['Cozy Bear', 'Midnight Blizzard', 'NOBELIUM'], - techniques: ['T1078', 'T1098', 'T1606'], - sectors: ['Government', 'Tech', 'Diplomatic'], - origin: ['Russia (SVR)'], - matchedAdversaries: 0, - lastActivity: days(32), - iocCount: 184, - description: - 'Russian state-sponsored APT focused on intelligence collection. Known for SolarWinds and Microsoft 365 token theft campaigns.', - }, - { - id: 'actor-lazarus', - name: 'Lazarus Group', - aliases: ['Hidden Cobra', 'APT38'], - techniques: ['T1190', 'T1059', 'T1486'], - sectors: ['Finance', 'Crypto', 'Defense'], - origin: ['North Korea (DPRK)'], - matchedAdversaries: 0, - lastActivity: days(60), - iocCount: 521, - description: 'DPRK state actor focused on financial theft and cryptocurrency targeting.', - }, -] - -const FEEDS: ThreatFeed[] = [ - { id: 'f-otx', name: 'AlienVault OTX', kind: 'open', status: 'healthy', itemsTotal: 4_120_044, itemsAdded24h: 8_421, lastSync: min(8), syncIntervalMin: 15 }, - { id: 'f-mandiant', name: 'Mandiant Advantage', kind: 'commercial', status: 'healthy', itemsTotal: 218_410, itemsAdded24h: 412, lastSync: min(22), syncIntervalMin: 60 }, - { id: 'f-abusech', name: 'AbuseCH (URLhaus + ThreatFox)', kind: 'open', status: 'healthy', itemsTotal: 891_201, itemsAdded24h: 2_104, lastSync: min(11), syncIntervalMin: 30 }, - { id: 'f-virustotal', name: 'VirusTotal', kind: 'commercial', status: 'healthy', itemsTotal: 3_420_115, itemsAdded24h: 14_872, lastSync: min(4), syncIntervalMin: 10 }, - { id: 'f-spamhaus', name: 'Spamhaus DROP/EDROP', kind: 'open', status: 'healthy', itemsTotal: 18_421, itemsAdded24h: 22, lastSync: hr(2), syncIntervalMin: 240 }, - { id: 'f-cisa', name: 'CISA Known Exploited Vulns (KEV)', kind: 'open', status: 'healthy', itemsTotal: 1_142, itemsAdded24h: 0, lastSync: hr(8), syncIntervalMin: 1440 }, - { id: 'f-nvd', name: 'NVD CVE feed', kind: 'open', status: 'stale', itemsTotal: 218_001, itemsAdded24h: 47, lastSync: hr(36), syncIntervalMin: 360 }, - { id: 'f-misp', name: 'Internal MISP', kind: 'internal', status: 'healthy', itemsTotal: 4_812, itemsAdded24h: 18, lastSync: min(2), syncIntervalMin: 5 }, - { id: 'f-twinds', name: 'ThreatWinds Galaxy', kind: 'commercial', status: 'healthy', itemsTotal: 12_491_201, itemsAdded24h: 34_120, lastSync: min(1), syncIntervalMin: 5 }, - { id: 'f-recorded', name: 'Recorded Future', kind: 'commercial', status: 'error', itemsTotal: 0, itemsAdded24h: 0, lastSync: hr(12), syncIntervalMin: 60 }, -] - -/* ─── Style maps ───────────────────────────────────────────────────────── */ - -const SEVERITY_STYLE: Record = { - critical: { bar: 'bg-red-500', label: 'Critical', tone: 'text-red-500' }, - high: { bar: 'bg-orange-500', label: 'High', tone: 'text-orange-500' }, - medium: { bar: 'bg-amber-500', label: 'Medium', tone: 'text-amber-500' }, - low: { bar: 'bg-sky-500', label: 'Low', tone: 'text-sky-500' }, -} - -const TYPE_META: Record = { - ipv4: { icon: Network, label: 'IPv4', tone: 'text-sky-500' }, - domain: { icon: Globe2, label: 'Domain', tone: 'text-violet-500' }, - sha256: { icon: Hash, label: 'SHA-256', tone: 'text-fuchsia-500' }, - md5: { icon: Fingerprint, label: 'MD5', tone: 'text-pink-500' }, - url: { icon: LinkIcon, label: 'URL', tone: 'text-amber-500' }, - cve: { icon: Bug, label: 'CVE', tone: 'text-red-500' }, -} - -const FEED_KIND_TONE: Record = { - commercial: 'text-violet-500', - open: 'text-sky-500', - internal: 'text-emerald-500', -} - -const FEED_STATUS_META: Record = { - healthy: { dot: 'bg-emerald-500', label: 'Healthy' }, - stale: { dot: 'bg-amber-500', label: 'Stale' }, - error: { dot: 'bg-red-500 animate-pulse', label: 'Error' }, - paused: { dot: 'bg-muted-foreground', label: 'Paused' }, -} - -/* ─── Page ─────────────────────────────────────────────────────────────── */ - -type Tab = 'matched' | 'all' | 'actors' | 'feeds' +import { useTiConfigStatus } from '../hooks/use-ti-config-status' +import { NotConfiguredState } from '../components/NotConfiguredState' +import { ThreatIntelHeader } from '../components/ThreatIntelHeader' +import { MatchOverviewCard } from '../components/MatchOverviewCard' +import { IntelInsightsCard } from '../components/IntelInsightsCard' +import { LookupBar } from '../components/LookupBar' +import { TabsRow, type TabKey } from '../components/TabsRow' +import { IocTable } from '../components/IocTable' +import { IocDrawer } from '../components/IocDrawer' +import { ActorsList } from '../components/ActorsList' +import { ActorDrawer } from '../components/ActorDrawer' +import { FeedsList } from '../components/FeedsList' +import type { EntitySearchResponse, EntitySummary } from '../domain/threat-intel.types' export function ThreatIntelPage() { - const [tab, setTab] = useState('matched') + const { isConfigured, isLoading } = useTiConfigStatus() + const [results, setResults] = useState(null) + const [tab, setTab] = useState('iocs') + const [openIoc, setOpenIoc] = useState(null) + const [openActor, setOpenActor] = useState(null) const [search, setSearch] = useState('') - const [openIoc, setOpenIoc] = useState(null) - const [openActor, setOpenActor] = useState(null) - const matchedIocs = IOCS.filter((i) => i.matchesInEnv > 0) - const totalMatches = matchedIocs.reduce((s, i) => s + i.matchesInEnv, 0) + if (isLoading) return null + if (isConfigured === false) return + + const iocs: EntitySummary[] = results?.results ?? [] + // ponytail: actors derived by CM entity type until a dedicated actors endpoint exists. + const actors: EntitySummary[] = iocs.filter((e) => e.type === 'threat') return (
-
+
- +
@@ -310,11 +45,11 @@ export function ThreatIntelPage() {
- +
- +
@@ -333,7 +68,7 @@ export function ThreatIntelPage() { className="h-9 pl-9" />
- {(tab === 'matched' || tab === 'all') && ( + {tab === 'iocs' && ( <>
- {tab === 'matched' && } - {tab === 'all' && } - {tab === 'actors' && } - {tab === 'feeds' && } -
- - {openIoc && setOpenIoc(null)} />} - {openActor && setOpenActor(null)} />} -
- ) -} - -/* ─── Header ───────────────────────────────────────────────────────────── */ - -function Header({ total }: { total: number }) { - return ( -
-
- {total.toLocaleString()} matched in your env -
-
- - -
-
- ) -} - -/* ─── Match overview ───────────────────────────────────────────────────── */ - -function MatchOverviewCard({ total, matchedCount }: { total: number; matchedCount: number }) { - const buckets = 48 - const data = useMemo( - () => - Array.from({ length: buckets }, (_, i) => - Math.max( - 0, - Math.round(Math.abs(Math.sin(i / 5)) * 18 + Math.abs(Math.sin(i / 11)) * 8 + (i === 38 ? 12 : 0) + 3) - ) - ), - [] - ) - const w = 1000 - const h = 100 - const max = Math.max(...data) * 1.15 - const xs = data.map((_, i) => (i * w) / (data.length - 1)) - const ys = data.map((v) => h - (v / max) * h) - const linePath = data.reduce((acc, _, i) => { - if (i === 0) return `M ${xs[i]} ${ys[i]}` - const prevX = xs[i - 1] - const prevY = ys[i - 1] - const cx1 = prevX + (xs[i] - prevX) / 2 - const cx2 = xs[i] - (xs[i] - prevX) / 2 - return `${acc} C ${cx1} ${prevY}, ${cx2} ${ys[i]}, ${xs[i]} ${ys[i]}` - }, '') - const areaPath = `${linePath} L ${xs[xs.length - 1]} ${h} L ${xs[0]} ${h} Z` - - return ( -
-
-
-
- IOC matches · last 24 hours -
-
- {total.toLocaleString()} - - {matchedCount} distinct indicators - -
-
-
- - - - - - - - - - - - -
- 24h ago - 12h ago - now -
-
- ) -} - -/* ─── AI insights ──────────────────────────────────────────────────────── */ - -function IntelInsightsCard() { - return ( -
-
- -

SOC AI insights

+ {tab === 'iocs' && } + {tab === 'actors' && } + {tab === 'feeds' && }
-
- 3 adversaries in your env attributed to{' '} - Scattered Spider — campaign appears active.{' '} - -
- -
- 2 KEV CVEs applicable to your stack — no - patches deployed yet. -
- -
- 1 feed errored · Recorded Future auth - token expired 12h ago. -
-
- ) -} - -/* ─── Lookup bar ───────────────────────────────────────────────────────── */ - -function LookupBar() { - const [q, setQ] = useState('') - return ( -
-
- - Lookup any indicator -
-
-
- - setQ(e.target.value)} - placeholder="Paste an IP, domain, hash, URL, or CVE — we'll check feeds and your environment" - className="h-10 pl-9 font-mono text-sm" - /> -
- -
-
- ) -} - -/* ─── Tabs row ─────────────────────────────────────────────────────────── */ - -const TABS: { id: Tab; label: string; count: number }[] = [ - { id: 'matched', label: 'Matched in your env', count: IOCS.filter((i) => i.matchesInEnv > 0).length }, - { id: 'all', label: 'All IOCs', count: IOCS.length }, - { id: 'actors', label: 'Threat actors', count: ACTORS.length }, - { id: 'feeds', label: 'Feeds', count: FEEDS.length }, -] - -function TabsRow({ current, onChange }: { current: Tab; onChange: (t: Tab) => void }) { - return ( -
- {TABS.map((t) => { - const active = current === t.id - return ( - - ) - })} -
- ) -} - -/* ─── IOC table ────────────────────────────────────────────────────────── */ - -const IOC_COLS = '4px 90px 1fr 130px 70px 110px 100px 36px' - -function IocTable({ - iocs, - search, - onOpen, - matched, -}: { - iocs: IOC[] - search: string - onOpen: (i: IOC) => void - matched?: boolean -}) { - const filtered = iocs.filter((i) => - search - ? (i.value + i.tags.join(' ') + i.feeds.join(' ') + (i.attributedActor ?? '')) - .toLowerCase() - .includes(search.toLowerCase()) - : true - ) - - return ( -
-
-
-
Type
-
Indicator
-
Source
-
{matched ? 'Matches' : 'Severity'}
-
Tags
-
Last seen
-
-
- {filtered.map((i) => ( - onOpen(i)} matched={matched} /> - ))} - {filtered.length === 0 && ( -
No IOCs match.
- )} -
- ) -} - -function IocRow({ ioc, onOpen, matched }: { ioc: IOC; onOpen: () => void; matched?: boolean }) { - const sev = SEVERITY_STYLE[ioc.severity] - const t = TYPE_META[ioc.type] - const TIcon = t.icon - return ( -
- -
- - - {t.label} - -
-
-
{ioc.value}
- {ioc.attributedActor && ( -
- - {ioc.attributedActor} -
- )} -
-
- {ioc.feeds[0]} - {ioc.feeds.length > 1 && ( - +{ioc.feeds.length - 1} - )} -
-
- {matched ? ( - {ioc.matchesInEnv.toLocaleString()} - ) : ( - {sev.label} - )} -
-
- {ioc.tags.slice(0, 2).map((tag) => ( - - {tag} - - ))} - {ioc.tags.length > 2 && ( - +{ioc.tags.length - 2} - )} -
-
- {ioc.lastSeenInEnv ? relativeTime(ioc.lastSeenInEnv) : '—'} -
-
- -
+ setOpenIoc(null)} /> + setOpenActor(null)} />
) } - -/* ─── Actors list ──────────────────────────────────────────────────────── */ - -function ActorsList({ - search, - onOpen, -}: { - search: string - onOpen: (a: ThreatActor) => void -}) { - const filtered = ACTORS.filter((a) => - search - ? (a.name + a.aliases.join(' ') + a.techniques.join(' ') + a.sectors.join(' ')) - .toLowerCase() - .includes(search.toLowerCase()) - : true - ) - return ( -
- {filtered.map((a) => ( - onOpen(a)} /> - ))} -
- ) -} - -function ActorCard({ actor, onOpen }: { actor: ThreatActor; onOpen: () => void }) { - const inEnv = actor.matchedAdversaries > 0 - return ( - - ) -} - -function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) { - return ( -
-
{label}
-
{value}
-
- ) -} - -/* ─── Feeds list ───────────────────────────────────────────────────────── */ - -const FEED_COLS = '12px 1fr 110px 110px 100px 110px 36px' - -function FeedsList({ search }: { search: string }) { - const filtered = FEEDS.filter((f) => - search ? f.name.toLowerCase().includes(search.toLowerCase()) : true - ) - return ( -
-
-
-
Feed
-
Kind
-
Total
-
+24h
-
Last sync
-
-
- {filtered.map((f) => ( - - ))} - {filtered.length === 0 && ( -
No feeds match.
- )} -
- ) -} - -function FeedRow({ feed }: { feed: ThreatFeed }) { - const st = FEED_STATUS_META[feed.status] - return ( -
- -
-
{feed.name}
-
- syncs every{' '} - {feed.syncIntervalMin < 60 - ? `${feed.syncIntervalMin}m` - : feed.syncIntervalMin < 1440 - ? `${Math.round(feed.syncIntervalMin / 60)}h` - : `${Math.round(feed.syncIntervalMin / 1440)}d`} -
-
-
- {feed.kind} -
-
- {feed.itemsTotal.toLocaleString()} -
-
0 ? 'text-emerald-500' : 'text-muted-foreground' - )} - > - {feed.itemsAdded24h > 0 ? `+${feed.itemsAdded24h.toLocaleString()}` : '—'} -
-
- {relativeTime(feed.lastSync)} -
-
- -
-
- ) -} - -/* ─── IOC drawer ───────────────────────────────────────────────────────── */ - -function IocDrawer({ ioc, onClose }: { ioc: IOC; onClose: () => void }) { - const sev = SEVERITY_STYLE[ioc.severity] - const t = TYPE_META[ioc.type] - const TIcon = t.icon - return ( -
-
e.stopPropagation()} - > -
-
-
-
- - {t.label} - · - {sev.label} -
-

{ioc.value}

-
- {ioc.attributedActor && ( - - - {ioc.attributedActor} - - )} - {ioc.tags.map((tag) => ( - {tag} - ))} -
-
- -
- -
- - - - -
-
- -
-
-
-
- {ioc.matchesInEnv > 0 ? ( -
- - {ioc.matchesInEnv.toLocaleString()} events - - {ioc.firstSeenInEnv && ( - - {absTimestamp(ioc.firstSeenInEnv)} - - )} - {ioc.lastSeenInEnv && ( - - {absTimestamp(ioc.lastSeenInEnv)} - - )} -
- ) : ( -

- Not seen in your environment yet. Pre-block this IOC to prevent matches. -

- )} -
- -
-
    - {ioc.feeds.map((f) => ( -
  • - {f} - -
  • - ))} -
-
- -
-
- {['VirusTotal', 'AbuseIPDB', 'Shodan', 'AlienVault OTX', 'GreyNoise', 'Censys'].map( - (name) => ( - - ) - )} -
-
-
- - -
-
-
-
- ) -} - -/* ─── Actor drawer ─────────────────────────────────────────────────────── */ - -function ActorDrawer({ actor, onClose }: { actor: ThreatActor; onClose: () => void }) { - return ( -
-
e.stopPropagation()} - > -
-
-
-
0 - ? 'bg-fuchsia-500/15 text-fuchsia-500 ring-fuchsia-500/40' - : 'bg-muted text-foreground/80 ring-border' - )} - > - -
-
-

{actor.name}

-
- aka {actor.aliases.join(', ')} -
-
- {actor.matchedAdversaries > 0 && ( - - {actor.matchedAdversaries} adversaries in your env - - )} - - Origin · {actor.origin.join(', ')} - -
-
-
- -
- -
- - - -
-
- -
-
-
-

{actor.description}

-
- -
-
-
- {actor.techniques.map((t) => ( - - - {t} - - ))} -
-
- -
-
- {actor.sectors.map((s) => ( - {s} - ))} -
-
-
- -
-
- 0 ? 'text-fuchsia-500' : ''} /> - - -
-
-
-
-
-
- ) -} - -/* ─── Small parts ──────────────────────────────────────────────────────── */ - -function Section({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-

{title}

- {children} -
- ) -} - -function KV({ k, children }: { k: string; children: React.ReactNode }) { - return ( - <> -
{k}
-
{children}
- - ) -} - -function Metric({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ) -} - -/* ─── Helpers ──────────────────────────────────────────────────────────── */ - -function relativeTime(iso: string) { - const diff = NOW - new Date(iso).getTime() - const m = Math.round(diff / 60_000) - if (m < 1) return 'just now' - if (m < 60) return `${m}m ago` - const h = Math.round(m / 60) - if (h < 24) return `${h}h ago` - return `${Math.round(h / 24)}d ago` -} - -function absTimestamp(iso: string) { - return new Date(iso).toLocaleString(undefined, { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }) -} - -// Suppress potential unused-import warnings -void AlertTriangle 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 new file mode 100644 index 000000000..59adee9b6 --- /dev/null +++ b/frontend/src/features/threat-intel/services/threat-intel-http.service.ts @@ -0,0 +1,37 @@ +import { createApiClient } from '@/shared/lib/api-client' +import type { + EntitySearchRequest, + EntitySearchResponse, + EntityDetail, + EntityRelation, + ThreatFeed, + ChatRequest, + ChatResponse, + UsageInfo, + TiResult, +} from '../domain/threat-intel.types' +import { isNotConfigured } from './ti-errors' + +const api = createApiClient() +const BASE = '/threat-intel' + +async function wrap(fn: () => Promise): Promise> { + try { + return { kind: 'ok', value: await fn() } + } catch (e) { + if (isNotConfigured(e)) return { kind: 'not-configured' } + throw e + } +} + +export const threatIntelHttpService = { + search: (body: EntitySearchRequest) => + wrap(() => api.post(`${BASE}/search`, body)), + entity: (id: string) => + wrap(() => api.get(`${BASE}/entity/${encodeURIComponent(id)}`)), + relations: (id: string) => + wrap(() => api.get(`${BASE}/entity/${encodeURIComponent(id)}/relations`)), + feeds: () => wrap(() => api.get(`${BASE}/feeds`)), + chat: (body: ChatRequest) => wrap(() => api.post(`${BASE}/ai/chat`, body)), + usage: () => wrap(() => api.get(`${BASE}/usage`)), +} diff --git a/frontend/src/features/threat-intel/services/ti-errors.ts b/frontend/src/features/threat-intel/services/ti-errors.ts new file mode 100644 index 000000000..eb6639c59 --- /dev/null +++ b/frontend/src/features/threat-intel/services/ti-errors.ts @@ -0,0 +1,14 @@ +import { ApiError } from '@/shared/lib/api-client' + +export function isNotConfigured(e: unknown): boolean { + return e instanceof ApiError && e.status === 503 && /not configured/i.test(e.message ?? '') +} + +export function describeError(e: unknown): string { + if (e instanceof ApiError) { + if (e.status === 502) return 'Threat intel upstream unavailable — try again.' + return e.message || `Request failed (${e.status}).` + } + if (e instanceof Error) return e.message + return 'Unknown error' +} From 11b0f0773cbb01a2bef102825adf11913562efeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Fri, 10 Jul 2026 15:01:36 -0600 Subject: [PATCH 2/7] fix[frontend](threat_intelligence): improved table data fill and pagination --- .../threat-intel/components/FeedRow.tsx | 48 ++------ .../threat-intel/components/FeedsHeader.tsx | 15 +++ .../threat-intel/components/FeedsList.tsx | 34 +----- .../threat-intel/components/IocTable.tsx | 103 +++++++++++++---- .../threat-intel/components/LookupBar.tsx | 46 ++------ .../components/utils/severity-style.ts | 24 ++-- .../threat-intel/domain/threat-intel.types.ts | 21 ++-- .../threat-intel/pages/ThreatIntelPage.tsx | 106 +++++++++++++++--- 8 files changed, 231 insertions(+), 166 deletions(-) create mode 100644 frontend/src/features/threat-intel/components/FeedsHeader.tsx diff --git a/frontend/src/features/threat-intel/components/FeedRow.tsx b/frontend/src/features/threat-intel/components/FeedRow.tsx index 6e1747641..9bb56e878 100644 --- a/frontend/src/features/threat-intel/components/FeedRow.tsx +++ b/frontend/src/features/threat-intel/components/FeedRow.tsx @@ -1,57 +1,25 @@ -import { MoreHorizontal } from 'lucide-react' import { cn } from '@/shared/lib/utils' import type { ThreatFeed } from '../domain/threat-intel.types' -import { FEED_STATUS_META, FEED_KIND_TONE } from '../components/utils/severity-style' -import { relativeTime } from '../components/utils/time-format' +import { feedAccuracyMeta, feedTypeTone } from './utils/severity-style' interface FeedRowProps { feed: ThreatFeed } -const FEED_COLS = '12px 1fr 110px 110px 100px 110px 36px' +const FEED_COLS = '12px 1fr 160px 140px' export function FeedRow({ feed }: FeedRowProps) { - const st = FEED_STATUS_META[feed.status] + const acc = feedAccuracyMeta(feed.accuracy) return (
- -
-
{feed.name}
-
- syncs every{' '} - {feed.syncIntervalMin < 60 - ? `${feed.syncIntervalMin}m` - : feed.syncIntervalMin < 1440 - ? `${Math.round(feed.syncIntervalMin / 60)}h` - : `${Math.round(feed.syncIntervalMin / 1440)}d`} -
-
-
- {feed.kind} -
-
- {feed.itemsTotal.toLocaleString()} -
-
0 ? 'text-emerald-500' : 'text-muted-foreground' - )} - > - {feed.itemsAdded24h > 0 ? `+${feed.itemsAdded24h.toLocaleString()}` : '—'} -
-
- {relativeTime(feed.lastSync)} -
-
- -
+ +
{feed.name}
+
{feed.type}
+
{acc.label}
) } diff --git a/frontend/src/features/threat-intel/components/FeedsHeader.tsx b/frontend/src/features/threat-intel/components/FeedsHeader.tsx new file mode 100644 index 000000000..430d06f25 --- /dev/null +++ b/frontend/src/features/threat-intel/components/FeedsHeader.tsx @@ -0,0 +1,15 @@ +const FEED_COLS = '12px 1fr 160px 140px' + +export function FeedsHeader() { + return ( +
+
+
Feed
+
Type
+
Accuracy
+
+ ) +} diff --git a/frontend/src/features/threat-intel/components/FeedsList.tsx b/frontend/src/features/threat-intel/components/FeedsList.tsx index 0ecce75e2..6a0f71b63 100644 --- a/frontend/src/features/threat-intel/components/FeedsList.tsx +++ b/frontend/src/features/threat-intel/components/FeedsList.tsx @@ -1,29 +1,16 @@ import { useTiFeeds } from '../hooks/use-ti-feeds' import { FeedRow } from './FeedRow' - -const FEED_COLS = '12px 1fr 110px 110px 100px 110px 36px' +import { FeedsHeader } from './FeedsHeader' export function FeedsList() { const { data, isLoading } = useTiFeeds() - // Handle TiResult union if (data?.kind === 'not-configured') return null if (isLoading) { return (
-
-
-
Feed
-
Kind
-
Total
-
+24h
-
Last sync
-
-
+ {Array.from({ length: 5 }).map((_, i) => (
-
-
-
Feed
-
Kind
-
Total
-
+24h
-
Last sync
-
-
+
+ {feeds.map((feed) => ( - + ))}
) diff --git a/frontend/src/features/threat-intel/components/IocTable.tsx b/frontend/src/features/threat-intel/components/IocTable.tsx index ea1e864a3..39b0f9360 100644 --- a/frontend/src/features/threat-intel/components/IocTable.tsx +++ b/frontend/src/features/threat-intel/components/IocTable.tsx @@ -1,38 +1,95 @@ +import { useEffect, useRef } from 'react' import type { EntitySummary } from '../domain/threat-intel.types' +import { Pagination } from '@/shared/components/ui/pagination' import { IocRow } from './IocRow' interface IocTableProps { iocs: EntitySummary[] onOpen: (id: string) => void isLoading?: boolean + page: number + pageSize: number + totalItems: number + onPageChange: (page: number) => void + onPageSizeChange: (size: number) => void + hasMore?: boolean + onLoadMore?: () => void } const IOC_COLS = '4px 90px 1fr 130px 1fr 110px 36px' -export function IocTable({ iocs, onOpen, isLoading }: IocTableProps) { +export function IocTable({ + iocs, + onOpen, + isLoading, + page, + pageSize, + totalItems, + onPageChange, + onPageSizeChange, + hasMore, + onLoadMore, +}: IocTableProps) { + const scrollRef = useRef(null) + const sentinelRef = useRef(null) + + useEffect(() => { + const node = sentinelRef.current + const root = scrollRef.current + if (!node || !root || !hasMore || !onLoadMore) return + const io = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && !isLoading) onLoadMore() + }, + { root, rootMargin: '200px' } + ) + io.observe(node) + return () => io.disconnect() + }, [hasMore, onLoadMore, isLoading]) + return ( -
-
-
-
Type
-
Indicator
-
Reputation
-
Tags
-
Last seen
-
+ <> +
+
+
+
Type
+
Indicator
+
Reputation
+
Tags
+
Last seen
+
+
+
+ {iocs.map((ioc) => ( + onOpen(ioc.id)} /> + ))} + {!isLoading && iocs.length === 0 && ( +
No IOCs match.
+ )} +
+ {hasMore && ( +
+ {isLoading ? 'Loading more…' : `Loaded ${iocs.length} of ${totalItems}. Scroll for more.`} +
+ )} + {!hasMore && iocs.length > 0 && ( +
+ End of results ({iocs.length} of {totalItems}). +
+ )} +
- {iocs.map((ioc) => ( - onOpen(ioc.id)} /> - ))} - {!isLoading && iocs.length === 0 && ( -
No IOCs match.
- )} - {isLoading && ( -
Loading…
- )} -
+ + ) } diff --git a/frontend/src/features/threat-intel/components/LookupBar.tsx b/frontend/src/features/threat-intel/components/LookupBar.tsx index 889137525..cea845e84 100644 --- a/frontend/src/features/threat-intel/components/LookupBar.tsx +++ b/frontend/src/features/threat-intel/components/LookupBar.tsx @@ -1,55 +1,23 @@ -import { useEffect, useState } from 'react' +import { useState } from 'react' import { Crosshair, Search } from 'lucide-react' import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' -import { useTiSearch } from '../hooks/use-ti-search' -import type { EntitySearchResponse } from '../domain/threat-intel.types' interface LookupBarProps { - onResults: (data: EntitySearchResponse | null) => void + onSearch: (query: string) => void + isPending?: boolean } -export function LookupBar({ onResults }: LookupBarProps) { +export function LookupBar({ onSearch, isPending }: LookupBarProps) { const [q, setQ] = useState('') - const { mutate, isPending } = useTiSearch() - const handleSubmit = () => { - if (q.trim()) { - mutate( - { query: q }, - { - onSuccess: (data) => { - if (data?.kind === 'not-configured') { - onResults(null) - } else if (data?.kind === 'ok') { - onResults(data.value) - } - }, - } - ) - } + const trimmed = q.trim() + if (trimmed) onSearch(trimmed) } - useEffect(()=>{ - mutate( - { query: '*' }, - { - onSuccess: (data) => { - if (data?.kind === 'not-configured') { - onResults(null) - } else if (data?.kind === 'ok') { - onResults(data.value) - } - }, - } - ) - },[]) - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleSubmit() - } + if (e.key === 'Enter') handleSubmit() } return ( diff --git a/frontend/src/features/threat-intel/components/utils/severity-style.ts b/frontend/src/features/threat-intel/components/utils/severity-style.ts index 098329078..7d6b27b07 100644 --- a/frontend/src/features/threat-intel/components/utils/severity-style.ts +++ b/frontend/src/features/threat-intel/components/utils/severity-style.ts @@ -1,5 +1,5 @@ import { type LucideIcon, Bug, Fingerprint, Globe2, Hash, LinkIcon, Network, Skull } from 'lucide-react' -import type { EntityType, FeedKind, FeedStatus } from '../../domain/threat-intel.types' +import type { EntityType, FeedType, FeedAccuracy } from '../../domain/threat-intel.types' export type ReputationTone = 'danger' | 'warning' | 'neutral' | 'unknown' @@ -45,15 +45,19 @@ export function typeMeta(type: EntityType): { icon: LucideIcon; label: string; t return TYPE_TABLE[type] ?? TYPE_FALLBACK } -export const FEED_KIND_TONE: Record = { - commercial: 'text-violet-500', - open: 'text-sky-500', - internal: 'text-emerald-500', +const FEED_TYPE_TABLE: Partial> = { + accumulative: 'text-violet-500', +} +export function feedTypeTone(type: FeedType): string { + return FEED_TYPE_TABLE[type] ?? 'text-muted-foreground' } -export const FEED_STATUS_META: Record = { - healthy: { dot: 'bg-emerald-500', label: 'Healthy' }, - stale: { dot: 'bg-amber-500', label: 'Stale' }, - error: { dot: 'bg-red-500 animate-pulse', label: 'Error' }, - paused: { dot: 'bg-muted-foreground', label: 'Paused' }, +// ponytail: CM feed accuracy labels seen so far: "level1". Assume level1 > level2 > level3. +const FEED_ACCURACY_TABLE: Partial> = { + level1: { dot: 'bg-emerald-500', label: 'Level 1' }, + level2: { dot: 'bg-amber-500', label: 'Level 2' }, + level3: { dot: 'bg-red-500', label: 'Level 3' }, +} +export function feedAccuracyMeta(a: FeedAccuracy): { dot: string; label: string } { + return FEED_ACCURACY_TABLE[a] ?? { dot: 'bg-muted-foreground', label: a } } diff --git a/frontend/src/features/threat-intel/domain/threat-intel.types.ts b/frontend/src/features/threat-intel/domain/threat-intel.types.ts index 2d3248c9b..7e17bf810 100644 --- a/frontend/src/features/threat-intel/domain/threat-intel.types.ts +++ b/frontend/src/features/threat-intel/domain/threat-intel.types.ts @@ -93,27 +93,24 @@ export function searchItemValue(item: EntitySearchItem): string { export interface EntitySearchRequest { query: string types?: EntityType[] - limit?: number - offset?: number + page?: number + size?: number } export interface EntitySearchResponse { results: EntitySummary[] - total: number + items: number + pages: number + aggregations?: unknown | null } -export type FeedKind = 'commercial' | 'open' | 'internal' -export type FeedStatus = 'healthy' | 'stale' | 'error' | 'paused' +export type FeedType = 'accumulative' | (string & {}) +export type FeedAccuracy = 'level1' | 'level2' | 'level3' | (string & {}) export interface ThreatFeed { - id: string name: string - kind: FeedKind - status: FeedStatus - itemsTotal: number - itemsAdded24h: number - lastSync: string - syncIntervalMin: number + type: FeedType + accuracy: FeedAccuracy } export interface ChatMessage { diff --git a/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx b/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx index a81ce7ddd..ea2e1708a 100644 --- a/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx +++ b/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx @@ -1,8 +1,10 @@ -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { ChevronDown, Clock, ListFilter, RefreshCw, Search } from 'lucide-react' import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' import { useTiConfigStatus } from '../hooks/use-ti-config-status' +import { useTiFeeds } from '../hooks/use-ti-feeds' +import { useTiSearch } from '../hooks/use-ti-search' import { NotConfiguredState } from '../components/NotConfiguredState' import { ThreatIntelHeader } from '../components/ThreatIntelHeader' import { MatchOverviewCard } from '../components/MatchOverviewCard' @@ -17,27 +19,87 @@ import { FeedsList } from '../components/FeedsList' import type { EntitySearchResponse, EntitySummary } from '../domain/threat-intel.types' export function ThreatIntelPage() { - const { isConfigured, isLoading } = useTiConfigStatus() + const { isConfigured, isLoading: configLoading } = useTiConfigStatus() + const feedsQuery = useTiFeeds() + const searchMutation = useTiSearch() + + const [query, setQuery] = useState('*') + const [page, setPage] = useState(0) + const [size, setSize] = useState(20) const [results, setResults] = useState(null) + const [iocs, setIocs] = useState([]) + + // 'replace' when the user searches, jumps pages, or changes page size. + // 'append' when infinite scroll asks for the next page. + const modeRef = useRef<'replace' | 'append'>('replace') + // Guards against out-of-order responses (older mutation lands after newer). + const seqRef = useRef(0) + const [tab, setTab] = useState('iocs') const [openIoc, setOpenIoc] = useState(null) const [openActor, setOpenActor] = useState(null) - const [search, setSearch] = useState('') + const [uiSearch, setUiSearch] = useState('') - if (isLoading) return null + useEffect(() => { + if (!isConfigured) return + const my = ++seqRef.current + const mode = modeRef.current + searchMutation.mutate( + { query, page: page + 1, size }, + { + onSuccess: (data) => { + if (my !== seqRef.current) return + if (data?.kind === 'not-configured') return + if (data?.kind !== 'ok') return + setResults(data.value) + setIocs((prev) => + mode === 'append' ? [...prev, ...data.value.results] : data.value.results + ) + }, + } + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query, page, size, isConfigured]) + + if (configLoading) return null if (isConfigured === false) return - const iocs: EntitySummary[] = results?.results ?? [] - // ponytail: actors derived by CM entity type until a dedicated actors endpoint exists. const actors: EntitySummary[] = iocs.filter((e) => e.type === 'threat') + const feedsCount = feedsQuery.data?.kind === 'ok' ? feedsQuery.data.value.length : 0 + const totalItems = results?.items ?? 0 + const totalPages = results?.pages ?? 0 + const hasMore = page + 1 < totalPages + + const handleSearch = (q: string) => { + modeRef.current = 'replace' + setPage(0) + setQuery(q) + } + + const handlePageChange = (p: number) => { + modeRef.current = 'replace' + setPage(p) + } + + const handlePageSizeChange = (s: number) => { + modeRef.current = 'replace' + setSize(s) + setPage(0) + } + + const handleLoadMore = () => { + if (!hasMore || searchMutation.isPending) return + modeRef.current = 'append' + setPage((p) => p + 1) + } return (
- +
- +
@@ -45,11 +107,15 @@ export function ThreatIntelPage() {
- +
- +
@@ -63,8 +129,8 @@ export function ThreatIntelPage() { ? 'Search actors, aliases, techniques…' : 'Search IOC value, tag, source…' } - value={search} - onChange={(e) => setSearch(e.target.value)} + value={uiSearch} + onChange={(e) => setUiSearch(e.target.value)} className="h-9 pl-9" />
@@ -83,6 +149,7 @@ export function ThreatIntelPage() { )}
- {tab === 'iocs' && } + {tab === 'iocs' && ( + + )} {tab === 'actors' && } {tab === 'feeds' && }
From e408933ec53c47dc35e33d138ebf82cf4b49b2ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Fri, 10 Jul 2026 15:13:55 -0600 Subject: [PATCH 3/7] fix[backend](threat_inteliggense): added advanced search proxy request --- backend/modules/threatintel/handler/proxy.go | 12 ++++++------ .../modules/threatintel/internal/instanceconfig.go | 4 ++-- backend/modules/threatintel/routes.go | 8 ++++++-- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/backend/modules/threatintel/handler/proxy.go b/backend/modules/threatintel/handler/proxy.go index 9d77944ac..f5e3cca90 100644 --- a/backend/modules/threatintel/handler/proxy.go +++ b/backend/modules/threatintel/handler/proxy.go @@ -99,11 +99,11 @@ func (h *ReverseProxyHandler) HandleUsageEndpoint(c *gin.Context) { } allowed := map[string]struct{}{ - "Accept": {}, - "Content-Type": {}, - "User-Agent": {}, - "X-Request-Id": {}, - "X-Correlation-Id": {}, + "Accept": {}, + "Content-Type": {}, + "User-Agent": {}, + "X-Request-Id": {}, + "X-Correlation-Id": {}, } // Copy headers @@ -122,7 +122,7 @@ func (h *ReverseProxyHandler) HandleUsageEndpoint(c *gin.Context) { // Execute request client := &http.Client{ - Timeout: 20* time.Second, + Timeout: 20 * time.Second, } resp, err := client.Do(req) if err != nil { diff --git a/backend/modules/threatintel/internal/instanceconfig.go b/backend/modules/threatintel/internal/instanceconfig.go index 9dc1f03c1..844559f5c 100644 --- a/backend/modules/threatintel/internal/instanceconfig.go +++ b/backend/modules/threatintel/internal/instanceconfig.go @@ -28,12 +28,12 @@ func LoadInstanceConfig(updatesDir string) (*InstanceConfig, error) { configOnce.Do(func() { data, err := os.ReadFile(filepath.Join(updatesDir, "instance-config.yml")) if err != nil { - _ = catcher.Error("error loading instance configuration",err,map[string]any{}) + _ = catcher.Error("error loading instance configuration", err, map[string]any{}) return } var cfg InstanceConfig if err := yaml.Unmarshal(data, &cfg); err != nil { - _ = catcher.Error("error loading instance configuration",err,map[string]any{}) + _ = catcher.Error("error loading instance configuration", err, map[string]any{}) return } instanceConfig = &cfg diff --git a/backend/modules/threatintel/routes.go b/backend/modules/threatintel/routes.go index bbbf2e001..26781c72f 100644 --- a/backend/modules/threatintel/routes.go +++ b/backend/modules/threatintel/routes.go @@ -14,6 +14,10 @@ func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) { searchHandler := handler.NewReverseProxyHandler("/proxy/api/search/v1/entities/simple") g.POST("/search", searchHandler.Handle) + // POST /api/v1/threat-intel/search/advanced → /proxy/api/search/v1/entities/advanced + advancedSearchHandler := handler.NewReverseProxyHandler("/proxy/api/search/v1/entities/advanced") + g.POST("/search/advanced", advancedSearchHandler.Handle) + // GET /api/v1/threat-intel/entity/:id → /proxy/api/analytics/v1/entity/{id}/details g.GET("/entity/:id", func(c *gin.Context) { id := c.Param("id") @@ -43,7 +47,7 @@ func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) { g.GET("/usage", usageHandler.HandleUsageEndpoint) } -func sanitizeId(id string) string{ - replacer := strings.NewReplacer(".", "", "\\", "","/","") +func sanitizeId(id string) string { + replacer := strings.NewReplacer(".", "", "\\", "", "/", "") return replacer.Replace(id) } From 55c51cf5925eb4215cd523db8acc9c37a3a2335f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Fri, 10 Jul 2026 15:18:19 -0600 Subject: [PATCH 4/7] fix[frontend](threat_intelligence): added 24h last ioc results graph --- .../components/MatchOverviewCard.tsx | 68 +++++++++++-------- .../components/utils/hourly-buckets.ts | 22 ++++++ .../threat-intel/domain/threat-intel.types.ts | 39 +++++++++++ .../threat-intel/hooks/use-ti-iocs-24h.ts | 15 ++++ .../threat-intel/pages/ThreatIntelPage.tsx | 2 +- .../services/threat-intel-http.service.ts | 10 +++ 6 files changed, 126 insertions(+), 30 deletions(-) create mode 100644 frontend/src/features/threat-intel/components/utils/hourly-buckets.ts create mode 100644 frontend/src/features/threat-intel/hooks/use-ti-iocs-24h.ts diff --git a/frontend/src/features/threat-intel/components/MatchOverviewCard.tsx b/frontend/src/features/threat-intel/components/MatchOverviewCard.tsx index c4bbf86eb..41087aedd 100644 --- a/frontend/src/features/threat-intel/components/MatchOverviewCard.tsx +++ b/frontend/src/features/threat-intel/components/MatchOverviewCard.tsx @@ -1,36 +1,44 @@ import { useMemo } from 'react' +import { useTiIocs24h } from '../hooks/use-ti-iocs-24h' +import { fillHourlyBuckets } from './utils/hourly-buckets' -export interface MatchOverviewCardProps { - total?: number -} +export function MatchOverviewCard() { + const query = useTiIocs24h() + + const total = useMemo(() => { + if (query.data?.kind !== 'ok') return 0 + return query.data.value.items + }, [query.data]) + + const data = useMemo(() => { + if (query.data?.kind !== 'ok') return [] + const buckets = query.data.value.aggregations?.hourly_iocs?.buckets ?? [] + return fillHourlyBuckets(buckets).map((b) => b.count) + }, [query.data]) -export function MatchOverviewCard({ total = 0 }: MatchOverviewCardProps) { - // ponytail: hardcoded sparkline until CM exposes a timeseries endpoint - const buckets = 48 - const data = useMemo( - () => - Array.from({ length: buckets }, (_, i) => - Math.max( - 0, - Math.round(Math.abs(Math.sin(i / 5)) * 18 + Math.abs(Math.sin(i / 11)) * 8 + (i === 38 ? 12 : 0) + 3) - ) - ), - [] - ) const w = 1000 const h = 100 - const max = Math.max(...data) * 1.15 - const xs = data.map((_, i) => (i * w) / (data.length - 1)) + const max = data.length > 0 ? Math.max(...data) * 1.15 : 1 + const xs = data.map((_, i) => (i * w) / Math.max(data.length - 1, 1)) const ys = data.map((v) => h - (v / max) * h) - const linePath = data.reduce((acc, _, i) => { - if (i === 0) return `M ${xs[i]} ${ys[i]}` - const prevX = xs[i - 1] - const prevY = ys[i - 1] - const cx1 = prevX + (xs[i] - prevX) / 2 - const cx2 = xs[i] - (xs[i] - prevX) / 2 - return `${acc} C ${cx1} ${prevY}, ${cx2} ${ys[i]}, ${xs[i]} ${ys[i]}` - }, '') - const areaPath = `${linePath} L ${xs[xs.length - 1]} ${h} L ${xs[0]} ${h} Z` + + let linePath = '' + if (data.length > 0) { + linePath = data.reduce((acc, _, i) => { + if (i === 0) return `M ${xs[i]} ${ys[i]}` + const prevX = xs[i - 1] + const prevY = ys[i - 1] + const cx1 = prevX + (xs[i] - prevX) / 2 + const cx2 = xs[i] - (xs[i] - prevX) / 2 + return `${acc} C ${cx1} ${prevY}, ${cx2} ${ys[i]}, ${xs[i]} ${ys[i]}` + }, '') + } else { + linePath = `M 0 ${h} L ${w} ${h}` + } + + const areaPath = data.length > 0 ? `${linePath} L ${xs[xs.length - 1]} ${h} L ${xs[0]} ${h} Z` : linePath + + if (query.data?.kind === 'not-configured') return null return (
@@ -40,7 +48,9 @@ export function MatchOverviewCard({ total = 0 }: MatchOverviewCardProps) { IOC matches · last 24 hours
- {total.toLocaleString()} + + {query.isPending ? '—' : total.toLocaleString()} + total indicators
@@ -53,7 +63,7 @@ export function MatchOverviewCard({ total = 0 }: MatchOverviewCardProps) { - + {data.length > 0 && } [b.key, b.doc_count])) + + for (let i = 23; i >= 0; i--) { + const ts = roundedNow - i * 3600000 + hours.push({ ts, count: bucketMap.get(ts) ?? 0 }) + } + + return hours +} diff --git a/frontend/src/features/threat-intel/domain/threat-intel.types.ts b/frontend/src/features/threat-intel/domain/threat-intel.types.ts index 7e17bf810..ff2e88f14 100644 --- a/frontend/src/features/threat-intel/domain/threat-intel.types.ts +++ b/frontend/src/features/threat-intel/domain/threat-intel.types.ts @@ -137,3 +137,42 @@ export interface UsageInfo { export type TiResult = | { kind: 'ok'; value: T } | { kind: 'not-configured' } + +export interface AdvancedRangeCondition { + range: Record +} +export interface AdvancedTermCondition { + term: Record +} +export type AdvancedCondition = AdvancedRangeCondition | AdvancedTermCondition + +export interface AdvancedDateHistogram { + date_histogram: { field: string; interval: string } +} +export type AdvancedAggregation = AdvancedDateHistogram + +export interface AdvancedSearchRequest { + query?: { + must?: AdvancedCondition[] + should?: AdvancedCondition[] + must_not?: AdvancedCondition[] + filter?: AdvancedCondition[] + } + aggs?: Record +} + +export interface AggregationBucket { + key: number + key_as_string: string + doc_count: number +} +export interface AggregationResult { + buckets: AggregationBucket[] +} + +export interface AdvancedSearchResponse { + items: number + pages: number + results: EntitySearchItem[] + aggregations?: Record | null +} diff --git a/frontend/src/features/threat-intel/hooks/use-ti-iocs-24h.ts b/frontend/src/features/threat-intel/hooks/use-ti-iocs-24h.ts new file mode 100644 index 000000000..278d43217 --- /dev/null +++ b/frontend/src/features/threat-intel/hooks/use-ti-iocs-24h.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query' +import { threatIntelHttpService } from '../services/threat-intel-http.service' +import type { AdvancedSearchRequest } from '../domain/threat-intel.types' + +const REQ: AdvancedSearchRequest = { + query: { must: [{ range: { lastSeen: { gte: 'now-24h', lte: 'now' } } }] }, + aggs: { hourly_iocs: { date_histogram: { field: 'lastSeen', interval: 'hour' } } }, +} + +export function useTiIocs24h() { + return useQuery({ + queryKey: ['ti', 'iocs-24h'], + queryFn: () => threatIntelHttpService.searchAdvanced(REQ, { limit: 0, page: 1 }), + }) +} diff --git a/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx b/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx index ea2e1708a..504989dba 100644 --- a/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx +++ b/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx @@ -99,7 +99,7 @@ export function ThreatIntelPage() {
- +
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 59adee9b6..0991ffbc4 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 @@ -9,6 +9,8 @@ import type { ChatResponse, UsageInfo, TiResult, + AdvancedSearchRequest, + AdvancedSearchResponse, } from '../domain/threat-intel.types' import { isNotConfigured } from './ti-errors' @@ -27,6 +29,14 @@ 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)) + if (params?.page !== undefined) qs.set('page', String(params.page)) + const query = qs.toString() + const path = `${BASE}/search/advanced${query ? `?${query}` : ''}` + return wrap(() => api.post(path, body)) + }, entity: (id: string) => wrap(() => api.get(`${BASE}/entity/${encodeURIComponent(id)}`)), relations: (id: string) => From 2cf059df540bfafcf6200243a53ef7ad9f6e5321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Fri, 10 Jul 2026 15:20:26 -0600 Subject: [PATCH 5/7] fix[frontend](threat_intelligence): temporary remove the socai highlights --- .../components/IntelInsightsCard.tsx | 29 ------------------- .../threat-intel/pages/ThreatIntelPage.tsx | 10 ++----- 2 files changed, 2 insertions(+), 37 deletions(-) delete mode 100644 frontend/src/features/threat-intel/components/IntelInsightsCard.tsx diff --git a/frontend/src/features/threat-intel/components/IntelInsightsCard.tsx b/frontend/src/features/threat-intel/components/IntelInsightsCard.tsx deleted file mode 100644 index 807773ae7..000000000 --- a/frontend/src/features/threat-intel/components/IntelInsightsCard.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Sparkles } from 'lucide-react' - -export function IntelInsightsCard() { - // ponytail: static copy — swap to useTiChat prompt when product spec lands - return ( -
-
- -

SOC AI insights

-
- -
- 3 adversaries in your env attributed to{' '} - Scattered Spider — campaign appears active.{' '} - -
- -
- 2 KEV CVEs applicable to your stack — no - patches deployed yet. -
- -
- 1 feed errored · Recorded Future auth - token expired 12h ago. -
-
- ) -} diff --git a/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx b/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx index 504989dba..2a5d8537b 100644 --- a/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx +++ b/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx @@ -8,7 +8,6 @@ import { useTiSearch } from '../hooks/use-ti-search' import { NotConfiguredState } from '../components/NotConfiguredState' import { ThreatIntelHeader } from '../components/ThreatIntelHeader' import { MatchOverviewCard } from '../components/MatchOverviewCard' -import { IntelInsightsCard } from '../components/IntelInsightsCard' import { LookupBar } from '../components/LookupBar' import { TabsRow, type TabKey } from '../components/TabsRow' import { IocTable } from '../components/IocTable' @@ -97,13 +96,8 @@ export function ThreatIntelPage() {
-
-
- -
-
- -
+
+
From 6a793173045c6eb5ca2bc855397112850947b740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Fri, 10 Jul 2026 15:26:45 -0600 Subject: [PATCH 6/7] fix[frontend](threat_intelligence): setted up option (export and set) buttons --- .../components/ThreatIntelHeader.tsx | 15 ++++--- .../threat-intel/pages/ThreatIntelPage.tsx | 41 ++++++++++++++++++- .../src/features/threat-intel/services/csv.ts | 24 +++++++++++ 3 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 frontend/src/features/threat-intel/services/csv.ts diff --git a/frontend/src/features/threat-intel/components/ThreatIntelHeader.tsx b/frontend/src/features/threat-intel/components/ThreatIntelHeader.tsx index 4738d9a41..3665be70a 100644 --- a/frontend/src/features/threat-intel/components/ThreatIntelHeader.tsx +++ b/frontend/src/features/threat-intel/components/ThreatIntelHeader.tsx @@ -1,25 +1,24 @@ -import { Download, FileText } from 'lucide-react' +import { Download } from 'lucide-react' import { Button } from '@/shared/components/ui/button' export interface ThreatIntelHeaderProps { matchedCount?: number onRefresh?: () => void + onExport?: () => void + isExporting?: boolean } -export function ThreatIntelHeader({ matchedCount }: ThreatIntelHeaderProps) { +export function ThreatIntelHeader({ matchedCount, onExport, isExporting }: ThreatIntelHeaderProps) { + const canExport = !!onExport && !!matchedCount && !isExporting return (
{matchedCount?.toLocaleString() || 0} matched in your env
- -
diff --git a/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx b/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx index 2a5d8537b..484c68560 100644 --- a/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx +++ b/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx @@ -2,9 +2,14 @@ import { useEffect, useRef, useState } from 'react' import { ChevronDown, Clock, ListFilter, RefreshCw, Search } from 'lucide-react' import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' +import { toast } from 'sonner' import { useTiConfigStatus } from '../hooks/use-ti-config-status' import { useTiFeeds } from '../hooks/use-ti-feeds' import { useTiSearch } from '../hooks/use-ti-search' +import { threatIntelHttpService } from '../services/threat-intel-http.service' +import { describeError } from '../services/ti-errors' +import { downloadCsv, toCsv } from '../services/csv' +import { searchItemValue } from '../domain/threat-intel.types' import { NotConfiguredState } from '../components/NotConfiguredState' import { ThreatIntelHeader } from '../components/ThreatIntelHeader' import { MatchOverviewCard } from '../components/MatchOverviewCard' @@ -38,6 +43,7 @@ export function ThreatIntelPage() { const [openIoc, setOpenIoc] = useState(null) const [openActor, setOpenActor] = useState(null) const [uiSearch, setUiSearch] = useState('') + const [isExporting, setIsExporting] = useState(false) useEffect(() => { if (!isConfigured) return @@ -92,9 +98,42 @@ export function ThreatIntelPage() { setPage((p) => p + 1) } + const handleExport = async () => { + if (!totalItems || isExporting) return + setIsExporting(true) + try { + const res = await threatIntelHttpService.search({ query, page: 1, size: totalItems }) + if (res.kind !== 'ok') return + const rows = res.value.results.map((r) => [ + r.id, + r.type, + searchItemValue(r), + r.tags.join('|'), + r.lastSeen, + r.reputation, + r.bestReputation, + r.worstReputation, + r.accuracy, + ]) + const csv = toCsv( + ['id', 'type', 'value', 'tags', 'lastSeen', 'reputation', 'bestReputation', 'worstReputation', 'accuracy'], + rows, + ) + downloadCsv(`iocs-${new Date().toISOString().replace(/[:.]/g, '-')}.csv`, csv) + } catch (e) { + toast.error(describeError(e)) + } finally { + setIsExporting(false) + } + } + return (
- +
diff --git a/frontend/src/features/threat-intel/services/csv.ts b/frontend/src/features/threat-intel/services/csv.ts new file mode 100644 index 000000000..bfaeecc7a --- /dev/null +++ b/frontend/src/features/threat-intel/services/csv.ts @@ -0,0 +1,24 @@ +// 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 +} + +export function toCsv(headers: string[], rows: (string | number | null | undefined)[][]): string { + const lines = [headers.map(csvCell).join(',')] + for (const r of rows) lines.push(r.map(csvCell).join(',')) + return lines.join('\n') +} + +export function downloadCsv(filename: string, csv: string): void { + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) +} From c9633e6226879bace925a1417f0ad74702ff9582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Fri, 10 Jul 2026 16:46:28 -0600 Subject: [PATCH 7/7] fix[frontend](threat_intelligence): added internacionalization for every threatintelligence text --- .../threat-intel/components/ActorCard.tsx | 6 +- .../threat-intel/components/ActorDrawer.tsx | 24 ++-- .../threat-intel/components/ActorsList.tsx | 4 +- .../threat-intel/components/FeedRow.tsx | 6 +- .../threat-intel/components/FeedsHeader.tsx | 9 +- .../threat-intel/components/FeedsList.tsx | 4 +- .../threat-intel/components/IocDrawerBody.tsx | 60 ++++----- .../components/IocDrawerLoading.tsx | 4 +- .../threat-intel/components/IocRow.tsx | 14 +- .../threat-intel/components/IocTable.tsx | 18 +-- .../threat-intel/components/LookupBar.tsx | 8 +- .../components/MatchOverviewCard.tsx | 12 +- .../components/NotConfiguredState.tsx | 6 +- .../threat-intel/components/TabsRow.tsx | 25 ++-- .../components/ThreatIntelHeader.tsx | 6 +- .../components/utils/severity-style.ts | 40 +++--- .../threat-intel/pages/ThreatIntelPage.tsx | 14 +- frontend/src/shared/i18n/locales/de.json | 98 ++++++++++++++ frontend/src/shared/i18n/locales/en.json | 123 ++++++++++++++++++ frontend/src/shared/i18n/locales/es.json | 98 ++++++++++++++ frontend/src/shared/i18n/locales/fr.json | 98 ++++++++++++++ frontend/src/shared/i18n/locales/it.json | 98 ++++++++++++++ frontend/src/shared/i18n/locales/pt.json | 98 ++++++++++++++ frontend/src/shared/i18n/locales/ru.json | 98 ++++++++++++++ 24 files changed, 861 insertions(+), 110 deletions(-) diff --git a/frontend/src/features/threat-intel/components/ActorCard.tsx b/frontend/src/features/threat-intel/components/ActorCard.tsx index 4c58acb43..cc329dd58 100644 --- a/frontend/src/features/threat-intel/components/ActorCard.tsx +++ b/frontend/src/features/threat-intel/components/ActorCard.tsx @@ -1,7 +1,8 @@ +import { useTranslation } from 'react-i18next' import { UserX } from 'lucide-react' import { cn } from '@/shared/lib/utils' import { searchItemValue, type EntitySummary } from '../domain/threat-intel.types' -import { REPUTATION_STYLE, reputationLabel, reputationTone } from './utils/severity-style' +import { REPUTATION_STYLE, reputationLabelKey, reputationTone } from './utils/severity-style' import { Stat } from './Stat' import { relativeTime } from './utils/time-format' @@ -11,6 +12,7 @@ interface ActorCardProps { } export function ActorCard({ actor, onOpen }: ActorCardProps) { + const { t } = useTranslation() const tone = reputationTone(actor.reputation) const rep = REPUTATION_STYLE[tone] const dangerous = tone === 'danger' @@ -38,7 +40,7 @@ export function ActorCard({ actor, onOpen }: ActorCardProps) {
{searchItemValue(actor)} - {reputationLabel(actor.reputation)} + {t(reputationLabelKey(actor.reputation))}
{actor.tags.length > 0 && ( diff --git a/frontend/src/features/threat-intel/components/ActorDrawer.tsx b/frontend/src/features/threat-intel/components/ActorDrawer.tsx index 4aceb3c3f..8bd8790ef 100644 --- a/frontend/src/features/threat-intel/components/ActorDrawer.tsx +++ b/frontend/src/features/threat-intel/components/ActorDrawer.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import { Crosshair, ExternalLink, Search, UserX, X } from 'lucide-react' import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/components/ui/button' @@ -13,6 +14,7 @@ interface ActorDrawerProps { } export function ActorDrawer({ id, onClose }: ActorDrawerProps) { + const { t } = useTranslation() const { data, isLoading } = useTiEntity(id) if (!id) return null @@ -79,7 +81,7 @@ export function ActorDrawer({ id, onClose }: ActorDrawerProps) { {a.reputation} - Accuracy: {a.accuracy} + {t('threatIntel.drawer.accuracy')} {a.accuracy}
@@ -94,15 +96,15 @@ export function ActorDrawer({ id, onClose }: ActorDrawerProps) {
@@ -110,21 +112,21 @@ export function ActorDrawer({ id, onClose }: ActorDrawerProps) {
{a.description && ( -
+

{a.description}

)} -
+
- - - + + +
{associations.length > 0 && ( -
+
    {associations.slice(0, 10).map((r) => { const rt = typeMeta(r.type) @@ -137,7 +139,7 @@ export function ActorDrawer({ id, onClose }: ActorDrawerProps) {
    - {rt.label} + {t(rt.labelKey)} {r.value}
    diff --git a/frontend/src/features/threat-intel/components/ActorsList.tsx b/frontend/src/features/threat-intel/components/ActorsList.tsx index 79dc53626..74dcd7f8c 100644 --- a/frontend/src/features/threat-intel/components/ActorsList.tsx +++ b/frontend/src/features/threat-intel/components/ActorsList.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import type { EntitySummary } from '../domain/threat-intel.types' import { ActorCard } from './ActorCard' @@ -8,6 +9,7 @@ interface ActorsListProps { } export function ActorsList({ actors, onOpen, isLoading }: ActorsListProps) { + const { t } = useTranslation() if (isLoading) { return (
    @@ -24,7 +26,7 @@ export function ActorsList({ actors, onOpen, isLoading }: ActorsListProps) { if (actors.length === 0) { return (
    - No actors found. + {t('threatIntel.actors.empty')}
    ) } diff --git a/frontend/src/features/threat-intel/components/FeedRow.tsx b/frontend/src/features/threat-intel/components/FeedRow.tsx index 9bb56e878..5fa8f2a1f 100644 --- a/frontend/src/features/threat-intel/components/FeedRow.tsx +++ b/frontend/src/features/threat-intel/components/FeedRow.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import { cn } from '@/shared/lib/utils' import type { ThreatFeed } from '../domain/threat-intel.types' import { feedAccuracyMeta, feedTypeTone } from './utils/severity-style' @@ -9,6 +10,7 @@ interface FeedRowProps { const FEED_COLS = '12px 1fr 160px 140px' export function FeedRow({ feed }: FeedRowProps) { + const { t } = useTranslation() const acc = feedAccuracyMeta(feed.accuracy) return ( @@ -16,10 +18,10 @@ export function FeedRow({ feed }: FeedRowProps) { className="group grid items-center gap-3 border-b border-border/60 px-4 py-2.5 text-xs last:border-b-0 hover:bg-muted/40" style={{ gridTemplateColumns: FEED_COLS }} > - +
    {feed.name}
    {feed.type}
    -
    {acc.label}
    +
    {t(acc.labelKey)}
    ) } diff --git a/frontend/src/features/threat-intel/components/FeedsHeader.tsx b/frontend/src/features/threat-intel/components/FeedsHeader.tsx index 430d06f25..00da0697a 100644 --- a/frontend/src/features/threat-intel/components/FeedsHeader.tsx +++ b/frontend/src/features/threat-intel/components/FeedsHeader.tsx @@ -1,15 +1,18 @@ +import { useTranslation } from 'react-i18next' + const FEED_COLS = '12px 1fr 160px 140px' export function FeedsHeader() { + const { t } = useTranslation() return (
    -
    Feed
    -
    Type
    -
    Accuracy
    +
    {t('threatIntel.feeds.table.name')}
    +
    {t('threatIntel.feeds.table.type')}
    +
    {t('threatIntel.feeds.table.accuracy')}
    ) } diff --git a/frontend/src/features/threat-intel/components/FeedsList.tsx b/frontend/src/features/threat-intel/components/FeedsList.tsx index 6a0f71b63..ec8d8159a 100644 --- a/frontend/src/features/threat-intel/components/FeedsList.tsx +++ b/frontend/src/features/threat-intel/components/FeedsList.tsx @@ -1,8 +1,10 @@ +import { useTranslation } from 'react-i18next' import { useTiFeeds } from '../hooks/use-ti-feeds' import { FeedRow } from './FeedRow' import { FeedsHeader } from './FeedsHeader' export function FeedsList() { + const { t } = useTranslation() const { data, isLoading } = useTiFeeds() if (data?.kind === 'not-configured') return null @@ -26,7 +28,7 @@ export function FeedsList() { if (feeds.length === 0) { return (
    - No feeds configured. + {t('threatIntel.feeds.empty')}
    ) } diff --git a/frontend/src/features/threat-intel/components/IocDrawerBody.tsx b/frontend/src/features/threat-intel/components/IocDrawerBody.tsx index 01373c12e..8516f29c3 100644 --- a/frontend/src/features/threat-intel/components/IocDrawerBody.tsx +++ b/frontend/src/features/threat-intel/components/IocDrawerBody.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import { ExternalLink, Globe2, MapPin, MoreHorizontal, Search, Shield, Sparkles, X } from 'lucide-react' import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/components/ui/button' @@ -15,11 +16,12 @@ interface IocDrawerBodyProps { } export function IocDrawerBody({ detail, relations, onClose }: IocDrawerBodyProps) { + const { t } = useTranslation() const a = detail.attributes const tone = reputationTone(a.reputation_score) const rep = REPUTATION_STYLE[tone] - const t = typeMeta(a.type) - const TIcon = t.icon + const typeMeta_ = typeMeta(a.type) + const TIcon = typeMeta_.icon const associations = detail.latest_associations ?? [] const merged = relations.length > 0 ? relations : associations const meta = detail.metadata @@ -31,12 +33,12 @@ export function IocDrawerBody({ detail, relations, onClose }: IocDrawerBodyProps
    - - {t.label} + + {t(typeMeta_.labelKey)} · {a.reputation} · - Accuracy: {a.accuracy} + {t('threatIntel.drawer.accuracy')} {a.accuracy}

    {a.value} @@ -65,15 +67,15 @@ export function IocDrawerBody({ detail, relations, onClose }: IocDrawerBodyProps

    diff --git a/frontend/src/features/threat-intel/components/MatchOverviewCard.tsx b/frontend/src/features/threat-intel/components/MatchOverviewCard.tsx index 41087aedd..f15976a57 100644 --- a/frontend/src/features/threat-intel/components/MatchOverviewCard.tsx +++ b/frontend/src/features/threat-intel/components/MatchOverviewCard.tsx @@ -1,8 +1,10 @@ import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' import { useTiIocs24h } from '../hooks/use-ti-iocs-24h' import { fillHourlyBuckets } from './utils/hourly-buckets' export function MatchOverviewCard() { + const { t } = useTranslation() const query = useTiIocs24h() const total = useMemo(() => { @@ -45,13 +47,13 @@ export function MatchOverviewCard() {
    - IOC matches · last 24 hours + {t('threatIntel.overview.title')}
    {query.isPending ? '—' : total.toLocaleString()} - total indicators + {t('threatIntel.overview.totalIndicators')}
    @@ -76,9 +78,9 @@ export function MatchOverviewCard() {
    - 24h ago - 12h ago - now + {t('threatIntel.overview.axis.start')} + {t('threatIntel.overview.axis.middle')} + {t('threatIntel.overview.axis.end')}
    ) diff --git a/frontend/src/features/threat-intel/components/NotConfiguredState.tsx b/frontend/src/features/threat-intel/components/NotConfiguredState.tsx index 652aa54f1..66483106a 100644 --- a/frontend/src/features/threat-intel/components/NotConfiguredState.tsx +++ b/frontend/src/features/threat-intel/components/NotConfiguredState.tsx @@ -1,15 +1,17 @@ +import { useTranslation } from 'react-i18next' import { ShieldOff } from 'lucide-react' export function NotConfiguredState() { + const { t } = useTranslation() return (
    -

    Threat Intelligence isn't configured

    +

    {t('threatIntel.notConfigured.title')}

    - Contact your administrator to connect an upstream Cyber Mantra / ThreatWinds instance for this environment. + {t('threatIntel.notConfigured.body')}

    diff --git a/frontend/src/features/threat-intel/components/TabsRow.tsx b/frontend/src/features/threat-intel/components/TabsRow.tsx index 7801b3364..22d3b1e60 100644 --- a/frontend/src/features/threat-intel/components/TabsRow.tsx +++ b/frontend/src/features/threat-intel/components/TabsRow.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import { cn } from '@/shared/lib/utils' export type TabKey = 'iocs' | 'actors' | 'feeds' @@ -8,28 +9,28 @@ export interface TabsRowProps { counts?: { iocs?: number; actors?: number; feeds?: number } } -const TABS: { id: TabKey; label: string }[] = [ - { id: 'iocs', label: 'IOCs' }, - { id: 'actors', label: 'Threat actors' }, - { id: 'feeds', label: 'Feeds' }, -] - export function TabsRow({ active, onChange, counts }: TabsRowProps) { + const { t } = useTranslation() + const TABS: { id: TabKey; labelKey: string }[] = [ + { id: 'iocs', labelKey: 'threatIntel.tabs.iocs' }, + { id: 'actors', labelKey: 'threatIntel.tabs.actors' }, + { id: 'feeds', labelKey: 'threatIntel.tabs.feeds' }, + ] return (
    - {TABS.map((t) => { - const tabActive = active === t.id - const count = counts?.[t.id] ?? 0 + {TABS.map((tab) => { + const tabActive = active === tab.id + const count = counts?.[tab.id] ?? 0 return (
    diff --git a/frontend/src/features/threat-intel/components/utils/severity-style.ts b/frontend/src/features/threat-intel/components/utils/severity-style.ts index 7d6b27b07..f0bb50e82 100644 --- a/frontend/src/features/threat-intel/components/utils/severity-style.ts +++ b/frontend/src/features/threat-intel/components/utils/severity-style.ts @@ -29,19 +29,27 @@ export function reputationLabel(score: number): string { return 'Trustworthy' } -const TYPE_FALLBACK = { icon: Fingerprint, label: 'Entity', tone: 'text-muted-foreground' } as const +export function reputationLabelKey(score: number): string { + if (score <= -3) return 'threatIntel.reputation.alarming' + if (score <= -1) return 'threatIntel.reputation.suspicious' + if (score === 0) return 'threatIntel.reputation.indefinable' + if (score === 1) return 'threatIntel.reputation.fair' + return 'threatIntel.reputation.trustworthy' +} + +const TYPE_FALLBACK = { icon: Fingerprint, labelKey: 'threatIntel.entityTypes.entity', tone: 'text-muted-foreground' } as const -const TYPE_TABLE: Partial> = { - ip: { icon: Network, label: 'IP', tone: 'text-sky-500' }, - hostname: { icon: LinkIcon, label: 'Host', tone: 'text-amber-500' }, - domain: { icon: Globe2, label: 'Domain', tone: 'text-violet-500' }, - url: { icon: LinkIcon, label: 'URL', tone: 'text-amber-500' }, - hash: { icon: Hash, label: 'Hash', tone: 'text-fuchsia-500' }, - cve: { icon: Bug, label: 'CVE', tone: 'text-red-500' }, - threat: { icon: Skull, label: 'Threat', tone: 'text-red-500' }, +const TYPE_TABLE: Partial> = { + ip: { icon: Network, labelKey: 'threatIntel.entityTypes.ip', tone: 'text-sky-500' }, + hostname: { icon: LinkIcon, labelKey: 'threatIntel.entityTypes.hostname', tone: 'text-amber-500' }, + domain: { icon: Globe2, labelKey: 'threatIntel.entityTypes.domain', tone: 'text-violet-500' }, + url: { icon: LinkIcon, labelKey: 'threatIntel.entityTypes.url', tone: 'text-amber-500' }, + hash: { icon: Hash, labelKey: 'threatIntel.entityTypes.hash', tone: 'text-fuchsia-500' }, + cve: { icon: Bug, labelKey: 'threatIntel.entityTypes.cve', tone: 'text-red-500' }, + threat: { icon: Skull, labelKey: 'threatIntel.entityTypes.threat', tone: 'text-red-500' }, } -export function typeMeta(type: EntityType): { icon: LucideIcon; label: string; tone: string } { +export function typeMeta(type: EntityType): { icon: LucideIcon; labelKey: string; tone: string } { return TYPE_TABLE[type] ?? TYPE_FALLBACK } @@ -53,11 +61,11 @@ export function feedTypeTone(type: FeedType): string { } // ponytail: CM feed accuracy labels seen so far: "level1". Assume level1 > level2 > level3. -const FEED_ACCURACY_TABLE: Partial> = { - level1: { dot: 'bg-emerald-500', label: 'Level 1' }, - level2: { dot: 'bg-amber-500', label: 'Level 2' }, - level3: { dot: 'bg-red-500', label: 'Level 3' }, +const FEED_ACCURACY_TABLE: Partial> = { + level1: { dot: 'bg-emerald-500', labelKey: 'threatIntel.feedAccuracy.level1' }, + level2: { dot: 'bg-amber-500', labelKey: 'threatIntel.feedAccuracy.level2' }, + level3: { dot: 'bg-red-500', labelKey: 'threatIntel.feedAccuracy.level3' }, } -export function feedAccuracyMeta(a: FeedAccuracy): { dot: string; label: string } { - return FEED_ACCURACY_TABLE[a] ?? { dot: 'bg-muted-foreground', label: a } +export function feedAccuracyMeta(a: FeedAccuracy): { dot: string; labelKey: string } { + return FEED_ACCURACY_TABLE[a] ?? { dot: 'bg-muted-foreground', labelKey: a } } diff --git a/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx b/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx index 484c68560..ee2fe52b5 100644 --- a/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx +++ b/frontend/src/features/threat-intel/pages/ThreatIntelPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import { ChevronDown, Clock, ListFilter, RefreshCw, Search } from 'lucide-react' import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' @@ -23,6 +24,7 @@ import { FeedsList } from '../components/FeedsList' import type { EntitySearchResponse, EntitySummary } from '../domain/threat-intel.types' export function ThreatIntelPage() { + const { t } = useTranslation() const { isConfigured, isLoading: configLoading } = useTiConfigStatus() const feedsQuery = useTiFeeds() const searchMutation = useTiSearch() @@ -157,10 +159,10 @@ export function ThreatIntelPage() { setUiSearch(e.target.value)} @@ -171,17 +173,17 @@ export function ThreatIntelPage() { <> )}