Catalyst / admin/SAynapse-Horizon 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / SAynapse-Horizon

public

Self Hosted Cyber Threat Intelligence Hub

Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
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>
  )
}