admin / SAynapse-Horizon
publicSelf Hosted Cyber Threat Intelligence Hub
SAynapse-Horizon / Synapse-Horizon / src / components / CTIFeed.jsx
5974 B · main
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | import { useMemo, useState } from 'react' import { ListFilter, Waves, Search, Inbox } from 'lucide-react' import { SOURCE_MAP } from '../data/sources' import { severityMeta, accentChip } from '../utils/theme' import { formatUtcTime, relativeAge } from '../utils/format' // Primary live-scrolling normalized CTI feed. export default function CTIFeed({ rows, loading, onSelect, displayTick }) { const [query, setQuery] = useState('') const [sevFilter, setSevFilter] = useState('all') const filtered = useMemo(() => { const q = query.trim().toLowerCase() return rows.filter((r) => { if (sevFilter !== 'all' && r.severity !== sevFilter) return false if (!q) return true return ( r.headline.toLowerCase().includes(q) || r.indicator.toLowerCase().includes(q) || r.target.toLowerCase().includes(q) ) }) }, [rows, query, sevFilter]) return ( <section className="flex min-h-0 flex-1 flex-col rounded-xl border border-slate-800/80 bg-slate-900/40"> <header className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-800/70 px-4 py-2.5"> <div className="flex items-center gap-2"> <Waves className="h-4 w-4 text-sky-400" /> <h2 className="text-sm font-semibold text-slate-100">Normalized CTI Feed</h2> <span className="rounded bg-slate-700/50 px-1.5 py-px text-[10px] font-semibold text-slate-300"> {filtered.length} </span> </div> <div className="flex items-center gap-2"> <div className="hidden items-center gap-1 sm:flex"> {['all', 'critical', 'high', 'medium', 'low'].map((s) => ( <button key={s} onClick={() => setSevFilter(s)} className={`rounded px-2 py-1 text-[11px] font-medium capitalize transition-colors ${ sevFilter === s ? 'bg-slate-700 text-slate-100' : 'text-slate-500 hover:text-slate-300' }`} > {s} </button> ))} </div> <div className="relative"> <Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" /> <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Filter indicators…" className="w-40 rounded-md border border-slate-700/70 bg-slate-950/60 py-1.5 pl-7 pr-2 text-xs text-slate-200 placeholder:text-slate-600 focus:border-sky-500/50 focus:outline-none focus:ring-1 focus:ring-sky-500/40" /> </div> </div> </header> <div className="min-h-0 flex-1 overflow-y-auto"> {loading ? ( <FeedSkeleton /> ) : filtered.length === 0 ? ( <FeedEmpty hasRows={rows.length > 0} /> ) : ( <ul className="divide-y divide-slate-800/60"> {filtered.map((r) => ( <FeedRow key={r.id} row={r} onSelect={onSelect} displayTick={displayTick} /> ))} </ul> )} </div> </section> ) } function FeedRow({ row, onSelect, displayTick }) { const sev = severityMeta(row.severity) const src = SOURCE_MAP[row.source] return ( <li className={`group flex animate-slide-in items-center gap-3 px-4 py-2.5 transition-colors ${sev.row}`}> <span className={`h-2 w-2 shrink-0 rounded-full ${sev.dot}`} title={sev.label} /> <div className="w-[92px] shrink-0 font-mono text-[11px] leading-tight text-slate-500"> <div className="text-slate-400">{formatUtcTime(row.detectedAt)}</div> <div className="text-[10px]">{relativeAge(row.detectedAt, displayTick)}</div> </div> <span className={`hidden w-[104px] shrink-0 justify-center truncate rounded px-1.5 py-1 text-[10px] font-semibold sm:inline-flex ${accentChip( src?.accent )}`} title={src?.name} > {src?.name} </span> <p className="min-w-0 flex-1 truncate text-sm text-slate-200" title={row.headline}> {row.headline} </p> <button type="button" onClick={() => onSelect(row)} className="shrink-0 rounded-md border border-slate-700/70 bg-slate-800/40 px-2.5 py-1 text-[11px] font-medium text-slate-300 opacity-80 transition-all hover:border-sky-500/50 hover:bg-sky-500/10 hover:text-sky-300 group-hover:opacity-100" > View Details </button> </li> ) } function FeedSkeleton() { return ( <ul className="divide-y divide-slate-800/60"> {Array.from({ length: 10 }).map((_, i) => ( <li key={i} className="flex items-center gap-3 px-4 py-3"> <div className="h-2 w-2 rounded-full bg-slate-800" /> <div className="h-6 w-[92px] animate-pulse rounded bg-slate-800/70" /> <div className="hidden h-5 w-[104px] animate-pulse rounded bg-slate-800/70 sm:block" /> <div className="h-3.5 flex-1 animate-pulse rounded bg-slate-800" style={{ maxWidth: `${55 + ((i * 7) % 35)}%` }} /> <div className="h-6 w-20 animate-pulse rounded bg-slate-800/70" /> </li> ))} </ul> ) } function FeedEmpty({ hasRows }) { return ( <div className="flex flex-col items-center justify-center gap-2 px-4 py-16 text-center"> {hasRows ? ( <ListFilter className="h-7 w-7 text-slate-600" /> ) : ( <Inbox className="h-7 w-7 text-slate-600" /> )} <p className="text-sm font-medium text-slate-400"> {hasRows ? 'No indicators match the current filter' : 'No indicators in active channels'} </p> <p className="text-[11px] text-slate-500"> {hasRows ? 'Clear the search box or severity filter to widen the view.' : 'Toggle a feed source on in the control panel to begin ingestion.'} </p> </div> ) } |